samagotchi 0.168.0 → 0.169.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/docs/architecture.md +1 -1
  4. data/docs/configuration.md +1 -1
  5. data/docs/guardrails.md +2 -2
  6. data/docs/hooks.md +2 -2
  7. data/docs/internals/block-renderers.md +1 -1
  8. data/docs/internals/bridge.md +16 -4
  9. data/docs/internals/inline-widgets.md +1 -1
  10. data/docs/internals/objectives.md +2 -1
  11. data/docs/internals/remote-sessions.md +1 -1
  12. data/docs/internals/web-frontend.md +62 -22
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +10 -6
  15. data/docs/testing.md +12 -0
  16. data/lib/samagotchi/bridge.rb +12 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/client_id.rb +3 -0
  19. data/lib/samagotchi/command_intake.rb +7 -7
  20. data/lib/samagotchi/compat.rb +14 -5
  21. data/lib/samagotchi/engine/objectives.rb +10 -1
  22. data/lib/samagotchi/guardrails/approvals.rb +8 -3
  23. data/lib/samagotchi/session_continue.rb +8 -11
  24. data/lib/samagotchi/session_manager/removal.rb +3 -3
  25. data/lib/samagotchi/session_stores.rb +79 -0
  26. data/lib/samagotchi/turn_stages/objectives.rb +6 -8
  27. data/lib/samagotchi/version.rb +1 -1
  28. data/lib/samagotchi/web/public/app.js +3 -1
  29. data/lib/samagotchi/web/public/block_watch.js +1 -1
  30. data/lib/samagotchi/web/public/cards_place.js +5 -1
  31. data/lib/samagotchi/web/public/index.html +58 -39
  32. data/lib/samagotchi/web/public/markdown_place.js +1 -1
  33. data/lib/samagotchi/web/public/question_cards.js +20 -6
  34. data/lib/samagotchi/web/public/stage_asking.js +283 -0
  35. data/lib/samagotchi/web/public/stage_frame.js +7 -11
  36. data/lib/samagotchi/web/public/stage_model.js +37 -11
  37. data/lib/samagotchi/web/public/stage_opened.js +28 -18
  38. data/lib/samagotchi/web/public/stage_slots.js +13 -89
  39. data/lib/samagotchi/web/public/stage_view.js +202 -160
  40. data/lib/samagotchi/web/public/stage_waiting.js +180 -0
  41. data/lib/samagotchi/web/public/stream_handlers.js +1 -1
  42. data/lib/samagotchi/web/public/turn_model.js +1 -1
  43. data/lib/samagotchi/web/session_show.rb +14 -4
  44. data/lib/samagotchi/worker.rb +3 -4
  45. metadata +4 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6bc9c5e654d2f08b834fbfac7be6b27f308a4a5c0753fe1c2472ef73e2b7c38a
4
- data.tar.gz: 44d7d0b2790f7182625a876b45e5e3172f41ce4cadd41209df3b7851f8b9d725
3
+ metadata.gz: 329ec9300af5c8d50782f89e0b422192dde8893d14c1cebe1a096e5512095e4c
4
+ data.tar.gz: 138fde389b9f7776b24db545b27c5550cc47a0565cf93141320fc7ba13ddb997
5
5
  SHA512:
6
- metadata.gz: e1424a5719445c61ac2a1904bb4cd6b097b7f7426b3bcfb1c7454f912edc2c14bda5a2574de21fb00f3ed3610e7ebaae2fe3bb3684fbc7f7b7f5c694115f7726
7
- data.tar.gz: 9fa6dba73441bad02e5f278c374208bb9e7aef298c4b23413a3a367d7190234e935d091bfb4b511978568ed7c68edc41ff539557bcd136a18a9026e95b11d930
6
+ metadata.gz: 743ca2eb1a501a506de420953b0d955ad9894b2fedc89880c767f53881afd8feae04468e44309b7883c2623354de87716d3d133602ff90a574da5d0cb3a3b0c7
7
+ data.tar.gz: becf43bf35201c4c78e946fec1356153a43199c863a99ab341f2406541d02e20e18236f8475ea3e30db5f13bc47342c6b30e6187e413d6fec7c6add57a48a073
data/CHANGELOG.md CHANGED
@@ -8,6 +8,42 @@ and commands may change between minor versions. How releases are made:
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.169.0] - 2026-11-12
12
+
13
+ ### Changed
14
+
15
+ - chi web: every card that waits for you docks alone above the composer, and the turn so far (your message, the steps
16
+ line) goes into the conversation: chi's question with or without a message before it, an approval, a delegate's
17
+ relayed card, a hook's question and check-in's card. chi's line before its own question or approval becomes a bubble
18
+ in the conversation (`chi · step N · asking you`, `↑ its reason` on the card); a relayed card, a hook's question or a
19
+ card with no line before it shows alone. The box has no steps chip once the turn is in the conversation. Folded, its
20
+ row says what the card asks (an approval: its summary and the command's start; check-in: its title).
21
+ - chi web: while check-in's card waits, the box's top row says the call chi runs now and the dot keeps blinking; when
22
+ chi writes with the card still open, the text streams in the box under the card and the row says "writing". The
23
+ composer no longer says a message waits for the card: check-in doesn't stop the turn.
24
+ - chi web: with two cards waiting at once (check-in still open, then a question or an approval) the box shows one at a
25
+ time, the one that stops chi in front, with `‹ 1 of 2 ›` in its top row to page between them.
26
+ - chi web: at the step limit the turn goes into the conversation at once with its card right under it, as a reload
27
+ draws it; no box waits with it.
28
+ - chi web: no amber on question and approval cards or on the box a card waits in (edge, tint, ▸, the option's radio);
29
+ amber stays on a waiting message's edge and "asking you", and on the box's "waiting for you".
30
+ - chi web: the question waiting above the composer has one fold, the stage's ▾. Folded, its row says "waiting for you"
31
+ and the question (on a phone, on a second line), so the conversation gets the room; a tap on the row brings the card
32
+ back. The fold is for that question only and isn't saved: a question always arrives open, also when you keep the
33
+ stage folded while chi works, and after the answer the stage is as you had it.
34
+
35
+ ### Fixed
36
+
37
+ - Bridge: a broken objectives store no longer breaks snapshots, SSE resets or attached clients; the web falls back to
38
+ its file view.
39
+ - Guardrails: approvals saved by chi 0.166 and before as a list are read and retained after the next write.
40
+ - chi web: after a reload, the objectives strip matches the running session's view; it no longer flips on update.
41
+ - Guardrails: approvals in an unversioned `approvals.json` object are read instead of set aside.
42
+ - chi web: a card waiting in the stage (a question, an approval) no longer closes to its title line in an empty box
43
+ when its title is clicked; its own ▾ is gone while it waits.
44
+
45
+ Update with `chi update`, then restart chi web and running sessions (`chi sessions restart`).
46
+
11
47
  ## [0.168.0] - 2026-11-11
12
48
 
13
49
  ### Changed
@@ -6239,7 +6275,8 @@ and long-lived sessions.
6239
6275
  - A macOS desktop helper (`chi desktop install`): a "Send to chi" Service and a
6240
6276
  hotkey panel that send selected text or the clipboard to your sessions.
6241
6277
 
6242
- [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.168.0...HEAD
6278
+ [Unreleased]: https://github.com/dm1try/samagotchi/compare/v0.169.0...HEAD
6279
+ [0.169.0]: https://github.com/dm1try/samagotchi/compare/v0.168.0...v0.169.0
6243
6280
  [0.168.0]: https://github.com/dm1try/samagotchi/compare/v0.167.0...v0.168.0
6244
6281
  [0.167.0]: https://github.com/dm1try/samagotchi/compare/v0.166.0...v0.167.0
6245
6282
  [0.166.0]: https://github.com/dm1try/samagotchi/compare/v0.165.0...v0.166.0
data/docs/architecture.md CHANGED
@@ -198,7 +198,7 @@ log's last 4 MB). Rows: [configuration.md](configuration.md#checking-it-chi-home
198
198
  - **Single owner:** the process running a session's Engine (its worker, or a `--non-interactive` one-shot) holds a flock on `owner.lock` (`OwnerLock`); a second owner backs off, and the web answers 409 for a session a one-shot owns. That owner is also the one writer of the session's `turn_drawings.jsonl` (a worker's Bridge, a one-shot's own accumulator); a fork's copy of its parent's lines is written before the child's worker spawns.
199
199
  - **Status:** `status` is turn state (`idle`/`running`); liveness is the lock. A stop is the `<uuid>/stopped` marker (`Session.mark_stopped`; a resume removes it), which `Session.load` and `.summary_from_file` read as status `stopped`, so no other process rewrites a worker's session file. A resume writes nothing else: the worker, holding the lock, turns a status from before it idle (`Worker#settle_stale_status`: `stopped` with no marker, `error`, a `running` no turn is behind; not a new session's `running` whose first prompt waits), and saves at start whatever the status, which stamps `updated_at` as the resume's save did (a session woken with no turn stays young for the sweep). N-1: a newer spawner starting an older worker (a checkout's chi spawning the installed one) loses that reset; a restart covers it.
200
200
  - **Starting:** every spawn marks the session starting (`<uuid>/starting`, `{pid, at}`, `StartingMarker`) until its worker holds the lock: `spawn_worker_for_session` writes it with its own pid before `Process.spawn` and the starter's after (the starter lives until the worker holds the lock, at most `WORKER_START_GRACE`; a spawn that raises or whose starter is gone leaves none), `spawn_session` before the new file is saved, and `Worker#start` removes it. It holds for 60 s at most, and only while the pid it names runs. A resume claims it (`StartingMarker.claim`, a link(2): of resumes racing from any processes one wins), then reads the owner again. A stop marker cancels the hold-off (`start_pending?` = starting and not stop-marked) for a start marked before the stop only: of two sends after a stop, the claim lets one spawn. The stop marker carries its own `at` (`{"at"}`, on the starting marker's clock; `Session.stopped_at`), an older chi's empty one its mtime. The sweep, archive (`:starting`) and delete (refused as an owned session) honour it. N-1: an older spawner writes none for a resume (the owner checks stay); an older worker never leaves one it was given (every worker clears it at start); a leftover one is stale by age or pid (a claim's temp files a kill left, `starting.*`, count as skeleton); a 0.160 spawner or chi web still holds off on a fresh marker under a stop (it reads `starting?` before the stop), until the start ends or 60 s. `SessionManager` keeps only its starters' pids, for chi web's restart to adopt (`live_spawn_pids`), and decides nothing from them.
201
- - **Deleting:** `delete_session`, the retention sweep and a failed continue's cleanup use `remove_files` for the session JSON, sidecar dir, plugin state, attached context, participant grants, System 1 runs and context-candidate records. The JSON and dir go through `remove_session_files`, holding the owner lock (kind `deleting`, which `stop_session` never signals; not waited for: a worker that took it since the check keeps the session) and reading the starting marker again under it (a resume that claimed the start just before keeps the session; one after it sees the lock held). N-1: an older chi's stop reading the owner then would TERM the deleter (chi web, the CLI) in that moment; an older reader shows the kind as unknown. `Session#save` of a session read from its file (or saved once) skips a file that is gone (log `save_skipped_deleted`) rather than recreate it; only a new session's first save creates one.
201
+ - **Deleting:** `delete_session`, the retention sweep and a failed continue's cleanup use `remove_files` for the session JSON, sidecar dir, plugin state, attached context, participant grants, System 1 runs and context-candidate records. `SessionStores` registers the sidecar filenames and the removal categories; `remove_session_files` deletes the whole sidecar directory, so those files need no individual removal path. The JSON and dir go through `remove_session_files`, holding the owner lock (kind `deleting`, which `stop_session` never signals; not waited for: a worker that took it since the check keeps the session) and reading the starting marker again under it (a resume that claimed the start just before keeps the session; one after it sees the lock held). N-1: an older chi's stop reading the owner then would TERM the deleter (chi web, the CLI) in that moment; an older reader shows the kind as unknown. `Session#save` of a session read from its file (or saved once) skips a file that is gone (log `save_skipped_deleted`) rather than recreate it; only a new session's first save creates one.
202
202
  - **Worker presence:** whether a worker is up, coming or held off is read once per decision as a `WorkerPresence` (`worker_presence.rb`, a `Data.define`), the deciders' one reader of those facts (display and list rows still read `session_owner` / `WorkerLink` themselves): the owner lock's holder, the stop marker (`stop_marked?`: the only "stopped" a decider reads, never the session file's status), the `starting` marker (`StartingMarker`, `starting?`, `start_pending?`: every spawn writes it, see Starting), the archive marker, the session file's existence, and the `WorkerLink` outcome when asked (never the file itself: a scratch session's flag is read by `WakePolicy`, last). The deciders read it: `resume_session` (tui, owned, `resume_held_off?`), the session inbox's `WakePolicy` (a ring, a push's wake, a note, a fresh verdict, `wake_for_report`, `deliver_turn`'s two liveness checks, `WorkerBoot`'s leave: below), `Local#wake`/`#worker`, `AttachLauncher`, `Schedules#busy_reason`, archive, delete and the retention sweep. Each keeps its own subset of the facts and their order.
203
203
  - **Session inbox:** a delivery into a session (`SessionInbox.deliver(kind, id) { |at| write }`, `session_inbox_delivery.rb`) runs one path: resolve the link (`SessionInbox::Head.resolve`: push and a fresh verdict are pinned to the id named; a send and a note go to the chain's newest link, and for a send (a kind that starts an archived link) a continue of the reached link in flight (`ContinueIntent.in_flight`: `continue.json` written, `continue.lock` held, the successor not saved yet) gives `:continuing` with the successor's id, which chi send asks again for a moment and then refuses; a note needs no such check (`NoteMove` moves or adopts it); a ring's file stays with the parent `parent.json` names and its wake goes one hop, to the link whose `ChildReports` reads that file; only an archived id is walked, with one `Session.list`: `SessionChain::Links#first_live`), refuse before any write (a gone session; a one-turn run's or a scratch session for send and a note), write with the kind's own writer (formats unchanged), read `WorkerPresence` once, and apply the kind's `WakePolicy` row. `decide` reads in one order for every kind: gone, one-turn run (`:owned`), owned (`:live`), the stop marker (`:stopped`: the only "stopped", the file's status never decides; send un-stops), archived (send alone starts one), starting (`:starting`, never a second spawn), the kind never starts (`:none`: a note, a fresh verdict) or wasn't asked to (`:off`), scratch (the session file is read only here), else `:start` through `SessionManager.start_for_inbox` (`resume_session`, its claim on the starting marker; it answers `:woken` only when it spawned the worker, `:starting` when another start won the claim, `:live` when a worker took the lock since). N-1 gap: a session file that says `stopped` with no stop marker (stopped before the marker existed, or a resume whose spawn failed after it cleared the marker) is woken, and an older worker, which has no `Worker#settle_stale_status`, exits on its first pass; the wake still reads `:woken`, and each later ring or push spawns another such worker (bounded by the rings and pushes). Restarting chi on the new version (`chi web`, the workers) closes it. `SessionInbox.wake` is the wake half alone (`wake_for_report`, a failed continue's rings). On it: `ChildRing.ring` (its start on a thread), `Local#context_push`'s wake (its outcome is `ContextPush#wake`, the push line's ending), `NoteDelivery.deliver` (every note writer a person or another session drives: `chi note`, `send_note`, `chi broadcast`, chi web's notes route; `NoteDelivery::Result#id` is the link that has it, `#asked` the one named; `/objectives` passes `pin: true`: its list is its own session's) and `VerifyFresh.forward` (`:fresh_verdict`: the child's parent, pinned, no worker). Send keeps its Bridge/file flow in `SessionManager.deliver_turn`, which resolves nothing (it acts on the id it is given: an archived link gets the message and comes back); chi send resolves the head first (`SessionAccess#head`, remotely `GET /api/sessions/resolve?head=1`), while the web composer, a participant's turn, `delegate` follow-ups, `Plugin::Sessions` and `Schedules` stay on the id they name; its two liveness checks (before the post, after a file's write) read the sender's row (`WakePolicy.for_sender(client_id)`, `ClientId.wake_kind`: a person's — chi send, a web tab, a TUI — is `:send`; a schedule, a plugin, a delegate follow-up and a participant have rows of their own, any other id `:automatic`, which start an idle session but neither un-stop nor start an archived link: refused before the write, `DeliverResult::STOPPED` / `ARCHIVED`) and leave the other refusals to `resume_session`'s wording. One row is not a delivery: `:leave`, `WorkerBoot.run_session_loop`'s successor as a worker leaves (input left after a signal, an idle exit's or `/exit`'s input or rings, a restart), read on a fresh presence once the lock is free: a stop that landed as it left starts none for any way out (`resume_session` would clear the stop; logged `respawn_dropped`, `restart_dropped` for a restart, whose reminders go with it), the input and rings stay for the next start. The notes a spawn writes into the session it starts stay direct `SessionInbox.write_note` calls, pinned by construction: spawn_session's chain note, `ContextMove`'s notes, `Objectives.carry`, `ChildMove`'s note to a moved child. A continue moves the previous link's waiting notes to the new link last in its `before_spawn` (`NoteMove.move`), a failed one gives them back before the link goes (`NoteMove.give_back`), and the new link's worker adopts what the archived links before it get after their continue (`NoteMove.adoption`, from `WorkerInbound#absorb_notes` and `Worker#start`: back along the chain through up to 8 archived links, from each only notes from the creation of the link that continued it, never an objectives list). `write_note` refuses a session with no file and no folder (`Session::NotFound`), so a late writer can't bring back a deleted link's folder.
204
204
  - **Retention:** 14 days / 500 cap (env `SAMAGOTCHI_SESSION_RETENTION_DAYS`/`MAX_COUNT`, optional `KEEP_STATUS`), live-owner and starting guard (looked at again right before a session's files go), only when `*.json` present; lazy sweep at most once per `session.sweep_interval_hours` (default 24) from the session hub's full-probe tick (and on `GET /api/sessions`, which the page no longer calls), manual via `bin/chi sessions prune --dry-run`.
@@ -2385,7 +2385,7 @@ column says so, a `--kebab-name` flag. The maps (`hosts:`, `models:`,
2385
2385
  | `web.host` | `127.0.0.1` | yes | `127.0.0.1`, `::1` or `localhost`; `lan` (this machine's private IPv4 address) or one of its IPv4 addresses also opens `chi web` to the network, with an access token, asked of this machine's 127.0.0.1 too (its `Local:` link carries a one-time code; `chi web --new-token` replaces the token; it is chi web's one access token, also used by `--tunnel` and `web.public_url`/a proxy). Anything else binds `127.0.0.1` with a warning. See [CLI: chi web on your phone](cli.md#chi-web-on-your-phone). |
2386
2386
  | `web.markdown` | `false` | yes | Render answers as Markdown in `chi web`. |
2387
2387
  | `web.working_set` | `true` | yes | The stage view's working set: the files, memories and URLs a turn brought into the model's context, in a rail in the stage, a footprint as each finished turn's block summary and a `set N` chip in place of the mem chip; `false` brings the mem chip back. `?working_set=1` or `?working_set=0` on the page URL overrides it for that page load. See [CLI: the working set](cli.md#the-working-set). |
2388
- | `web.reload_check` | `false` | yes | Opt-in: once each finished turn has moved into the history, the page reads the session again (one full read per turn end), draws that turn as a reload would and compares the two, strictly: a known divergence counts as a mismatch too. `chi web` logs one `reload_checked` line per check and a `reload_mismatch` line naming the kinds that differed, `excused` (the known divergences, `history_shape.js` `ACCEPTED`, that would explain them) and `explained` (all of them had one), with `turn_id`, `drawn` (the turn came back from its drawing, so a mismatch with `drawn: true` is a drawing bug), `undrawn` (how many of the reload's rows came without their turn's drawing, the page's plain fallback: 0 unless chi web missed a turn) and the worker's version: counts only, never content. The count is `grep -c reload_mismatch` over `grep -c reload_checked` in chi web's log, split by `excused`. Cards are left out: a reload places them after the render, which the check doesn't run (the e2e reload checks compare them). Every open owner tab runs the check; `chi web` logs a turn's first report only, and starts over when it restarts. |
2388
+ | `web.reload_check` | `false` | yes | Opt-in: once each finished turn has moved into the history, the page reads the session again (one full read per turn end), draws that turn as a reload would and compares the two, strictly: a known divergence counts as a mismatch too. `chi web` logs one `reload_checked` line per check and a `reload_mismatch` line naming the kinds that differed, `excused` (the known divergences, `history_shape.js` `ACCEPTED`, that would explain them) and `explained` (all of them had one), with `turn_id`, `drawn` (the turn came back from its drawing, so a mismatch with `drawn: true` is a drawing bug), `undrawn` (how many of the reload's rows came without their turn's drawing, the page's plain fallback: 0 unless chi web missed a turn) and the worker's version: counts only, never content. The count is `grep -c reload_mismatch` over `grep -c reload_checked` in chi web's log, split by `excused`. Cards and answered question cards are left out: a reload places them after the render, which the check doesn't run (the e2e reload checks compare them). Every open owner tab runs the check; `chi web` logs a turn's first report only, and starts over when it restarts. |
2389
2389
  | `web.annotate_presets` | `Agreed\|Could you please elaborate?` | yes | Quick replies next to Annotate in `chi web`, `\|`-separated (a YAML list works too); `""` in the file or on the CLI leaves only Annotate (an empty env value means the default). See [CLI](cli.md#web-annotate-presets). |
2390
2390
  | `web.editor` | `vscode` | yes | What a file ref in `chi web` (a file tool row's title, a diff line) opens, for a viewer on this machine: a preset (`vscode`, `vscode-insiders`, `cursor`, `zed`) or a URL template with `{path}` and an optional `{line}`; `none` turns refs off. See [Opening files from chi web](#opening-files-from-chi-web). |
2391
2391
  | `web.start_dir` | the folder `chi web` runs in | | The folder `chi web` starts in (`chi web --start-dir DIR` for one run): its page (the printed link, a bare `/`, the LAN link and QR code, the Home Screen app) opens on that folder's project, outside a project (no repository or marked folder) on every session, and a new session from the all view starts there; the header's project picker switches projects. `~` and a relative path (resolved from your home folder) work; one that isn't a folder stops `chi web` from starting. `chi web` never changes its own folder. See [CLI](cli.md). |
data/docs/guardrails.md CHANGED
@@ -115,8 +115,8 @@ once, session and repo, while `git merge y && git push` offers once, as `git-mer
115
115
  Two rules with one id (a config.yml copy of a bundle's rule) are one rule; a core ask followed by a rule's (the
116
116
  [child boundary's](#a-delegate-childs-boundary)) keeps its own scopes.
117
117
  The file is a versioned store (`{"version": 1, "approvals": [...]}`, `Approvals::FORMAT`); a write keeps its version,
118
- the keys this chi doesn't know and an entry with no `scope`. A bare list of entries, as chi 0.166 and before wrote it, is no longer read: it is set aside as
119
- `approvals.json.corrupt-*` like an unreadable file, so those calls ask once more. Chi 0.165 and before set a versioned store aside, so restart their sessions
118
+ the keys this chi doesn't know and an entry with no `scope`. A bare list of entries, as chi 0.166 and before wrote it,
119
+ is read and converted to a versioned store on the next write. Chi 0.165 and before set a versioned store aside, so restart their sessions
120
120
  (`chi sessions restart`); a downgrade to 0.165 or before moves the store to a recoverable `approvals.json.corrupt-*`.
121
121
  A file that doesn't parse, or a versioned store whose `approvals` isn't a list, is moved aside to
122
122
  `approvals.json.corrupt-<UTC time>` with one warning, and chi starts with no stored approvals (more asks, nothing lost).
data/docs/hooks.md CHANGED
@@ -61,7 +61,7 @@ The plugin class must respond to `#call(event)` — duck-typed, no base class re
61
61
  | `:after_generation` | After LLM returns (both loops); not after a generation a hook cut (`stop_generation`) | `{ type: :after_generation, iteration: N, response: "...", messages: [...] (the conversation as sent) }` |
62
62
  | `:generation_progress` | While the response streams, in batches (see [Watching the stream](#watching-the-stream)) | `{ type: :generation_progress, iteration: N, thinking: "..." (new since the last fire), text: "..." (new visible text), thinking_chars: N, text_chars: N (this generation so far), elapsed_ms: N, restarted: true (only on the first fire after a dropped stream was asked again) }` |
63
63
  | `:before_tool_call` | Before tool dispatch (and before `tool_call_started`) | `{ type: :before_tool_call, iteration: N, call: {...}, params: "...", guardrail: Verdict, context: {...}, targets: {...}, blocked: false, block_reason: nil }` |
64
- | `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the asking slot shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
64
+ | `:question_asked` | A question of the model's own (`ask_user_question`) or an approval of its tool call is about to open, on the turn's thread (not a hook's or plugin's `ask_user`, a relayed delegate approval, the step-limit question, a `--non-interactive` run or a stored approval: none of those fire it) | `{ type: :question_asked, kind: "question" \| "approval", text: "..." (the reply that asks, as the lead-in bubble shows it: no thoughts, no tool-call markup), question: {...} (the question's fields, frozen: question/options/header…, an approval's approval: facts too; no id yet), refs: (see [Refs on a question](#refs-on-a-question-display-only)) }` |
65
65
  | `:after_tool_call` | After tool execution | `{ type: :after_tool_call, iteration: N, tool: "read", output: "..." (the text the model gets: capped at max_tool_output_chars (a memory_read's cap is memory.read_max_chars, 0: none); a cut one has a [cut: …] line, at its end, or for an execute result between its head and its tail, the whole output saved in the session's tool_output/ folder), status: "ok" \| "error" \| "blocked" \| "stopped", call: {...} (the call that ran, as the gate left it), elapsed_ms: N (the tool's own run, without an approval wait; nil for a denied call), result: "..." (the tool's own text: no "[read]" lead, no "ran as:" line, never cut) }`; `stopped` is a `task_wait` the turn's Stop ended (the task runs on) or whose task was stopped. Match on `result:`, not `output:`: it is what the tool said (`Error: command timed out after 120s` for an execute killed at its limit in kill mode, `Samagotchi::Tools::Execute::TIMEOUT_PREFIX`; `Still running at the 120s limit: …` for one handed over as a task, `Samagotchi::Tools::Execute::HANDED_OVER`) |
66
66
  | `:session_end` | After each turn `:after_turn` fires for, after it (turn-level lifecycle) | `{ type: :session_end, session_id: "..." }` |
67
67
 
@@ -415,7 +415,7 @@ end
415
415
  The model never sees them.
416
416
  - The web checks them again wherever a saved one reaches it (the pending
417
417
  question, a saved card, the history), as an answer's, and pairs them as an
418
- answer's (the three passes above) in: the stage's asking slot while the
418
+ answer's (the three passes above) in: the lead-in bubble while the
419
419
  question waits (its raw text, then again once its markdown is in), the
420
420
  card's question text, the answered card's kept reply (raw, then its
421
421
  markdown when opened; never its one-line summary copy), and that step's
@@ -26,7 +26,7 @@ model. The TUI is untouched: it shows a fence as source.
26
26
  helpers live there too: `failBlock` and its `.block-note` (a click copies it), `holdingView`, `rejoin`, the
27
27
  toggle's listener; frames, the cache, hang marks, size checks, the zoom and the theme redraw stay in
28
28
  `block_host.js`. One `MutationObserver` per turn root,
29
- filtered to `TEXTS` (`text_roots.js`), so cards, the asking slot, question replies and delegate reports never draw, and
29
+ filtered to `TEXTS` (`text_roots.js`), so cards, a lead-in bubble, question replies and delegate reports never draw, and
30
30
  every path that adds a block to a root is seen the same way. One `IntersectionObserver` per scroller, with an explicit `root`
31
31
  (with `root: null`, `rootMargin` doesn't pass an `overflow: auto` in between). Closed `<details>` fire on open. **A
32
32
  move isn't over when it is seen:** a block waiting to come near is watched by its scroller's observer, so one that moves
@@ -132,7 +132,10 @@ no line for (an older worker's, one from before drawings, a dropped one) gets on
132
132
  `#saved_tool_records` through chi web for `--remote`) and the snapshot's cards, through the same renderer. Nothing
133
133
  rebuilds rows from the messages any more. `chi --remote` attaches through chi web's stream proxy,
134
134
  which relays an owner's frames byte for byte, the field included. A participant never gets it: its stream's snapshot
135
- is bare (`ParticipantView`). An older TUI ignores the field; an older worker sends none.
135
+ is bare (`ParticipantView`). The snapshot also carries `objectives`, this worker's `Engine#objectives_view` computed
136
+ with the same messages as its `:objectives_updated` event; the key is present (`nil` when there is no list), but is
137
+ omitted if reading the view fails so chi web can use its file view. An older worker sends no key. An older TUI
138
+ ignores the drawing field; an older worker sends none.
136
139
  - **The show.** chi web's full read (`GET /api/sessions/:id`, whatever its query: a tab from before 0.157 still sends
137
140
  `?parts=1`) draws every finished turn from a line: `SessionShow#drawn_lines` is `TurnDrawings.cover` over the file,
138
141
  the show's timing records and its cards (CardStore's list, live or saved), the running turn (`current_turn`'s id)
@@ -401,6 +404,10 @@ The Bridge exposes:
401
404
  the question still open. `chi answer` makes the same check first, from the session file.
402
405
  - `POST /session/:id/question/dismiss` — leave the question unanswered (an approval: denied);
403
406
  `200`, or `409` when it is no longer pending.
407
+ - `POST /session/:id/guardrails/wrong` — `{id}`: label this session's System 1 auto-allow as
408
+ one it should have asked about; `200`, `404` for an unknown judgment, `409` if already labelled.
409
+ - `POST /session/:id/holds/stop` — stop those holds without the worker leaving; `200 {status: "stopped",
410
+ session_id, stopped}` or `501 holds_unavailable`.
404
411
  - `POST /session/:id/tasks/stop` — `{task_id}`: stop a background task as the user
405
412
  (`TaskRuntime.stop_task by: "user"`, the web's stop-task button on a running `task_wait`;
406
413
  web route `POST /api/sessions/:id/tasks/:task_id/stop`). Only a task this conversation's
@@ -452,9 +459,14 @@ The Bridge exposes:
452
459
  - `GET /session/:id/state` — `session_state_snapshot` (JSON), read under the event lock so its `event_seq`/`event_id` match the state.
453
460
  - `GET /session/:id/stats` — `Engine#stats_snapshot` for attached `/stats`: the metrics, with the context window and prompt profile asked from the server before the first turn.
454
461
  - `GET /session/:id/activity` — `Worker#activity_snapshot`: `{session_id, turn_running, input_pending, awaiting_continue, open_streams, reminders, tasks_holding}` — what a `chi schedule tick` asks a live worker before it fires (the tick runs out of process and cannot read a worker's memory). Read-only and small (under 300 bytes). `501 activity_unavailable` when this Bridge was built without an `on_activity:` step; the worker advertises it in `Bridge::FEATURES` (`activity`). See [scheduling.md](../scheduling.md).
462
+ - `GET /session/:id/holds` — the reminders, watched tasks and delegate children the user can stop;
463
+ `200 {session_id, holds}` or `501 holds_unavailable`.
455
464
  - `GET /session/:id/snapshot` — the snapshot frame's content as one request (the web server renders
456
465
  the messages itself, then streams from its `event_seq`). Read by the web's full show
457
- (`GET /api/sessions/:id`) and the plugin's `list_sessions`; every message is copied.
466
+ (`GET /api/sessions/:id`) and `ctx.sessions.read` (`Plugin::Sessions#read`), which unwraps
467
+ `snapshot`; the Bridge reply is `{snapshot:, session_state_snapshot:}`. `snapshot.objectives` is the worker's
468
+ objectives view, including the running conversation; it is present as `nil` when the worker has no list and omitted
469
+ if reading the view fails so chi web can use its file view.
458
470
  - `GET /session/:id/tail` — the page's light re-read (`Bridge#tail_frame`): `session_state_snapshot`,
459
471
  `working_directory` (the session's folder, which the web checks the answer's file refs against),
460
472
  `answer` (the last message a UI shows as an answer, `AnswerTail`: the one message, copied alone),
@@ -539,8 +551,8 @@ The page's frequent reads of `GET /api/sessions/:id` stay light too:
539
551
  read's re-render) and pairs them with the bubble's links and text before the `path:line` scan. A question's
540
552
  refs (`pending_question.refs`, `:question_asked`) pair the same way (`markdown_place.js` `linkRefsIn`/`pairRefsIn`):
541
553
  `question_requested` (or a snapshot's pending question) keeps them on the asking step's gen
542
- (`turn_model.js` `questionRefs`) before the card draws, so the stage's asking slot (`stage_slots.js`
543
- `drawAsking(text, refs)`) pairs them on its raw draw and again after its markdown swap; the card pairs its
554
+ (`turn_model.js` `questionRefs`) before the card draws, so the lead-in bubble (`stage_opened.js`, its refs from
555
+ `turn_model.js askingRefs`) pairs them on its raw draw and again after its markdown swap; the card pairs its
544
556
  question text and its kept reply (`question_cards.js`); the step's closed `.gen-text` takes them
545
557
  (`turn_block.js`; on a reload its `data-answer-at`, the message's `display_refs`) and `linkTextRefs` pairs it at
546
558
  the turn's end and after a reload. A closed step's text is markdown too with `web.markdown` on: a reload's
@@ -138,7 +138,7 @@ strings reach the page through `textContent` and attributes only, never `innerHT
138
138
 
139
139
  **What doesn't draw.** The participant page (`participant.js` wires no block host), `web.markdown` off (no
140
140
  `data-lang`), a live answer while it streams (its YAML shows until the turn's end re-renders it as markdown), and
141
- anything outside `TEXTS`: cards, question replies, the asking slot, delegate reports. The terminal shows the YAML.
141
+ anything outside `TEXTS`: cards, question replies, a lead-in bubble, delegate reports. The terminal shows the YAML.
142
142
 
143
143
  ## The fixtures
144
144
 
@@ -279,7 +279,8 @@ never an approval, no coordinator path reads it, and nothing enters the conversa
279
279
  **Why not a `VerifyFresh` child.** The plan said "VerifyFresh-like, fit unchecked". It fits poorly:
280
280
 
281
281
  - `VerifyFresh.start` starts from a saved unclear verdict and its `VerifyAnchor`, a quote of at most 200 characters that
282
- `verify_anchor_source_session` holds against the parent's text. A move has neither, and a faked one would land in `session.verdicts`.
282
+ `TurnStages::VerifyAnchors#verified` checks against the source session's answer (`#source_session` loads the parent
283
+ for a fresh child). A move has neither, and a faked one would land in `session.verdicts`.
283
284
  - A child is an agent on the session's model with the owner's tools: an unknown number of requests, approvals waiting
284
285
  on the child, a place in `session.max_children` and in the session lists, and its spend outside the parent's line.
285
286
  Keeping it read-only and out of the delegate paths (`ChildRing`, `ChildrenStatus`) needs a new session field.
@@ -333,7 +333,7 @@ sends twice on a 408; 5 s, `JOIN_READ_TIMEOUT`, for the join's reads: the childr
333
333
 
334
334
  | chi web's answer | What the loop gets |
335
335
  |---|---|
336
- | 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`NO_WORKER`) |
336
+ | 503 `not_live`, 404 `not_found` | raises `Unreachable` (< `IOError`): "no live worker for session ID on NAME" (`WorkerLink.not_live` plus " on NAME", in `WebBridgeClient::Mapping.response`) |
337
337
  | 502 `bridge_secret` (stream) | `Refused(:bridge_secret)`; `EventStream` ends with `stream_closed bridge_secret` |
338
338
  | 504 (any) | `Response(408, {error: deadline_passed})`: the loop's late line |
339
339
  | 501 with `command` (`/command`) | `Response(400, {error: unknown_command, command})`: the loop names the command |
@@ -28,7 +28,7 @@ page. A factory dep a new version stops passing is the same hazard the other way
28
28
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
29
29
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
30
30
  `turn_range.js`, `history_reveal.js`, `latest.js`, `session_payload.js`, `method_api.js`, `session_store.js`,
31
- `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js`, `block_watch.js`, `card_stamps.js`, `recap_stamp.js` (a saved recap's place by its `opening_id` stamp, else its count), `inline_widgets.js`, `widget_walk.js`, `widget_picks.js`, `history_shape.js` (the history's shape, live against a reload: the e2e reload checks and the opt-in reload check share its normalizer and its `ACCEPTED` allowlist), `turn_pieces.js` (a finished turn's one order and its answer rule: the live hand-off's `handOffOrder` and `turnEnded`, and a reload's `turnHistoryNodes`), `reload_check.js` (the opt-in `web.reload_check`: after the stage's hand-off, the turn against a reload of it, the counts posted), `turn_drawing.js` (a finished turn's drawing, `turn_drawings` in the show: its fold into steps and its end kind; `turn_block.js` draws it), `stage_stall.js` (the stage's quiet signal), `history_end.js` (the stage's one move of pieces to the history's end), `stage_opened.js` (a running turn opened in the history while the model's own question waits: the reason bubble, the moves, the end fold), `stage_cards.js` (the cards that wait in the stage: `PENDING`, `ASKS`, `waitsInStage`, read by `stage_model.js placeKind`), `resync_reach.js` (a re-read that can't reach chi web keeps the drawn history; the status chip says retrying / offline, outside a turn), `session_sync.js` (the open session's sync: `select`, `resync` and its retries, the stream's life; the stream handle and the re-read bookkeeping are its own), `session_view.js` (the open session's view: `renderSessionView` draws a read, `forgetSession` resets the page for another session, `readTurnEnd`, `endTurnView`; app.js makes both right after the cards, each reaching the other and the later views through getters), `no_contact_fill.js` (a text field that isn't a contact form: autocomplete off and a name with no contact word, against iOS Safari's AutoFill Contact bar; a template's fields carry the same attributes, and its spec checks the page's markup for them) and the `objectives_*.js` modules are new modules for that reason. The last four `block_*` draw fenced blocks (`block_watch.js` finds and watches them, `block_host.js` is its frame drawer for a bundle's renderers): [block-renderers.md](block-renderers.md). `inline_widgets.js` is its core drawer for `chi-ui` blocks, drawing `widget_walk.js`'s and `widget_picks.js`'s widgets in a shadow root styled by `widgets.css`: [inline-widgets.md](inline-widgets.md).
31
+ `session_fields.js`, `feature_gates.js`, `revise_stack.js`, `revise_switch.js`, `shell_command.js`, `stale_params.js`, `popover_modal.js`, `more_sheet.js`, `hash_intent.js` (what the hash asks for: session, all, start or keep), `worker_features.js`, `version_notices.js`, `owner_claim.js`, `owner_verify.js`, `composer_mode.js`, `strip_hidden.js`, `block_open.js`, `text_roots.js`, `block_source.js`, `block_frame.js`, `block_host.js`, `block_watch.js`, `card_stamps.js`, `recap_stamp.js` (a saved recap's place by its `opening_id` stamp, else its count), `inline_widgets.js`, `widget_walk.js`, `widget_picks.js`, `history_shape.js` (the history's shape, live against a reload: the e2e reload checks and the opt-in reload check share its normalizer and its `ACCEPTED` allowlist), `turn_pieces.js` (a finished turn's one order and its answer rule: the live hand-off's `handOffOrder` and `turnEnded`, and a reload's `turnHistoryNodes`), `reload_check.js` (the opt-in `web.reload_check`: after the stage's hand-off, the turn against a reload of it, the counts posted), `turn_drawing.js` (a finished turn's drawing, `turn_drawings` in the show: its fold into steps and its end kind; `turn_block.js` draws it), `stage_stall.js` (the stage's quiet signal), `history_end.js` (the stage's one move of pieces to the history's end), `stage_opened.js` (a running turn opened in the history while a card waits: the lead-in bubble, the moves, the end fold), `stage_asking.js` (the stage's docked card and its fold: the docking and lead-in rules, the opening, a waiting card's own toggle, the folded row's text per kind, the fold's choice, reveal and the docked card's fold; `stage_view.js` wires it), `stage_waiting.js` (the stage's waiting cards as data, keyed by `data-qid` / `data-card-id`: kind, docked, approval, the stage's order, the shown card and its pager, the docked card's fold; `front`, `count`, `shown`, `blocking`), `stage_cards.js` (the cards that wait in the stage: `PENDING`, `ASKS`, `waitsInStage`, read by `stage_model.js placeKind`), `resync_reach.js` (a re-read that can't reach chi web keeps the drawn history; the status chip says retrying / offline, outside a turn), `session_sync.js` (the open session's sync: `select`, `resync` and its retries, the stream's life; the stream handle and the re-read bookkeeping are its own), `session_view.js` (the open session's view: `renderSessionView` draws a read, `forgetSession` resets the page for another session, `readTurnEnd`, `endTurnView`; app.js makes both right after the cards, each reaching the other and the later views through getters), `no_contact_fill.js` (a text field that isn't a contact form: autocomplete off and a name with no contact word, against iOS Safari's AutoFill Contact bar; a template's fields carry the same attributes, and its spec checks the page's markup for them) and the `objectives_*.js` modules are new modules for that reason. The last four `block_*` draw fenced blocks (`block_watch.js` finds and watches them, `block_host.js` is its frame drawer for a bundle's renderers): [block-renderers.md](block-renderers.md). `inline_widgets.js` is its core drawer for `chi-ui` blocks, drawing `widget_walk.js`'s and `widget_picks.js`'s widgets in a shadow root styled by `widgets.css`: [inline-widgets.md](inline-widgets.md).
32
32
 
33
33
  ## Two pages, two models
34
34
 
@@ -274,6 +274,7 @@ toast's fallback place (in a session `--toast-bottom` already sits it above the
274
274
  signed-out banner (`auth_lost.js`, `env()` itself: the participant page has no `--safe-*`). A new fixed or bottom-anchored
275
275
  piece reads `--safe-*`, never `env()`: the e2e set the names by hand (`web.spec.js`, a portrait phone and a landscape one),
276
276
  as Chromium has no insets. The theme-color metas are tokens.css's `--bg-top` per theme up to 600px wide (a phone's page starts from that colour at its top edge, so the status bar meets the top bar with no seam), else its `--bg` (`theme.test.js` pins them). An iOS 26 Home Screen app ignores them for the status bar strip: it paints the strip in the body's `background-color`, so a phone's body gradient keeps a `--bg-top` colour layer under it (black strip without one).
277
+ In a session, the dock (composer card) shares the history's centered column, at most 960px wide; its right edge follows a classic scrollbar (`--history-sb`, 0 with overlay scrollbars; index.html and `dock_reserve.js`).
277
278
 
278
279
  ## The stage's height
279
280
 
@@ -281,8 +282,8 @@ as Chromium has no insets. The theme-color metas are tokens.css's `--bg-top` per
281
282
  cap, and never above the session panel (less the card, its offset from the panel's bottom as computed: index.html
282
283
  `--dock-bottom`, and 16px). The frame is grow-only while a turn runs, so a change inside the stage never moves its top
283
284
  edge, with **two allowed shrinks, once per question and once per answer** (`forgetContent`):
284
- - **the asking form**: each model's own question that opens (or, the second one on, finds opened) the turn in the history
285
- (below): the stage shows that card alone (`stage_view.js place`, in the card's task);
285
+ - **the asking form**: each card that docks (it opens, or finds opened, the turn in the history, below): the stage shows
286
+ that card alone (`stage_view.js place`, in the card's task);
286
287
  - **the next step after a blocking card's answer** (`stage_view.js releaseHeldWait`, from `generationStarted`). The
287
288
  `held-wait` class says it is due: set with `waiting`, cleared there, so a later step, which calls it again, does
288
289
  nothing. What goes together: the live slots' waiting height, a raised cap, the tallest content seen. At the tap nothing
@@ -290,32 +291,69 @@ edge, with **two allowed shrinks, once per question and once per answer** (`forg
290
291
  the view (a reader who scrolled down a card taller than the stage), when it comes back to the view's top
291
292
  (`settleAnsweredCard`).
292
293
 
293
- One card raises the cap: a blocking approval too tall for the stage's view, a delegate's relayed one too
294
- (`raiseForApproval`, `stage_frame.js fitsCap`). It opens at its question and command (`revealTop`). A model's own
295
- question gets no room and no pill: its reply is in the history.
294
+ **The stage's fold** (`stage_model.js stageCollapsed`, `stage_asking.js`): collapsed to its status row the stage holds no
295
+ frame (`frameTarget` returns null) and an unfold refits to what shows. Three things decide it, kept apart: the user's
296
+ **choice** (the ▾ by hand, in memory, written to `chi_stage_collapsed`; never read back from storage to decide, so
297
+ another tab's write doesn't apply live), **revealed** (a ref's "show in turn" opened it for this turn; the choice stays)
298
+ and **the docked card's fold** (the waiting card the user folded, by its key). In the asking form only that fold counts:
299
+ the ▾ and a tap on the folded row set or clear it and write nothing (another blocking card that comes to wait meanwhile
300
+ drops it, so the stage opens on the front card), so a card always arrives open (in the card's task, before
301
+ `question_cards.js focusWhenIdle` focuses it) and a new one never matches an old fold; the same card drawn again with its
302
+ id keeps it. The fold reads the registry checked against the slots first (`reconcileWaiting`), so a card answered in
303
+ the same task no longer holds a click. When the form ends (an answer, a dismiss, a cancel; `emptySlots` for a reset, a
304
+ session switch, the next turn) the stage is as the choice and revealed say. `refresh` applies it (`drawCollapsed`: only
305
+ when it changed, no refresh of its own). Folded, the status row's `.ts-mini` says what the shown card asks
306
+ (`stage_asking.js foldedText`: a question's `.question-text`, an approval's summary and its command's first line, a
307
+ plugin card's `.card-title`; `foldedRowText`, `.ts-mini-question`: first line, the whole as its title, the UI font)
308
+ instead of the running call; on a phone the step and the timing leave the row and the text wraps to a second line.
309
+
310
+ There is no asking slot: the reply that asked is the lead-in bubble in the history, or nothing. One card raises the cap:
311
+ a blocking approval too tall for the stage's view, a delegate's relayed one too (`raiseForApproval`, the registry's
312
+ `approval` flag, `stage_frame.js fitsCap`). It opens at its question and command (`revealTop`).
296
313
 
297
314
  ## A turn opened in the history
298
315
 
299
- When the model's own question blocks the turn after a reply (`ASK_CARD`: `data-kind="question"`, not relayed, placed in
300
- the extras before the turn's end, with a reply from `stage_view.js askingGen`: live, the step whose call runs; in a
301
- replay, below), the stage opens the turn in the history
302
- (`stage_view.js openTurn`, `stage_opened.js`). Approvals, relayed and hook questions, a question with no reply, plugin
303
- asking cards and the step limit keep the stage as it was (the asking slot, `drawAsking`).
316
+ Every card that waits for the user docks (`stage_asking.js docks`: a `PENDING` card but the step limit, or an `ASKS`
317
+ card, placed before the turn's end): the stage opens the turn in the history (`stage_asking.js openTurn`,
318
+ `stage_opened.js`) and shows the card alone. **The lead-in:** chi's own question or approval
319
+ (`.bubble.question[data-kind="question"]:not([data-relay]), .bubble.question[data-kind="approval"]:not([data-relay])`)
320
+ whose step wrote text gets that text as a bubble in the history (`stage_asking.js leadInText`, the reply from
321
+ `askingGen`: live, the step whose call runs; in a replay, below); a relayed card, a hook's question and a card without
322
+ a lead-in message get none, and then a reader who was at the history's end follows the turn's move (`atEnd`, read
323
+ before it). **The step limit** never docks: its card arrives after the turn's end and hands the turn off at once
324
+ (`place` calls `handOff`, not the in-use watch; the end read doesn't hold it either), so the card sits right under its
325
+ turn in the history, as a reload draws it; a piece removed while the turn slides (the old continue card the step-limit
326
+ question replaces) stays gone.
304
327
 
305
328
  | Piece | Where, from the opening to the turn's end |
306
329
  |---|---|
307
- | The prompt, the steps block (closed; its steps in order from now on, `turn_block.js inOrder`) and the rows the extras held | the history's end, in `handOffOrder`'s order (`history_end.js moveToHistoryEnd`) |
330
+ | The prompt, the steps block (closed; its steps in order from now on, `turn_block.js inOrder`) and the rows the extras held | the history's end, in `handOffOrder`'s order (`history_end.js moveToHistoryEnd`). A lone step's block keeps its chrome there (`data-opened-turn`, until the hand-off), so its summary leads to the step's text when no lead-in shows it (a hook's question asked before its call's row) |
308
331
  | The reply that asked: `.bubble.said` (`data-question-id`, `data-said-turn`, never `data-turn-id`: that marks an opening) | the history's end, in the card's task; raw, then its markdown; `.pending` ("asking you", the amber edge) while its card waits |
309
332
  | Rows that arrive (`placeFor` `"opened"`: notes, steered prompts, commands, a card that stopped waiting) | the history's end, in arrival order |
310
- | A card that waits (`stage_cards.js waitsInStage`: `PENDING`, `ASKS`, a warning) | the stage, as before; a promoted one shows the asking form (`.asking-form`: the status row and the card; the prompt, the live slots with the working set and the chip hidden) and a "↑ its reason" link in its summary |
311
- | A promoted card answered or cancelled | right under its reason (`settle`), also after the turn's end; its own clamp is hidden while the reason is there (CSS) |
312
- | Live slots, the streaming answer, the end line, the queued slot | the stage, as before; the hand-off appends what it still holds |
333
+ | A card that waits (`stage_cards.js waitsInStage`: `PENDING`, `ASKS`, a warning) | the stage; a docked one shows the asking form (`.asking-form`: the status row and the card; the prompt, the live slots with the working set and the chip hidden), one with a lead-in a "↑ its reason" link in its summary. Two docked at once (check-in still open, then a question or approval): one at a time (`stage_waiting.js shown`; the others `.ts-behind`), the front one first, `‹ 1 of 2 ›` in the status row (`.ts-pager`). A warning stays beside the card. A `PENDING` card in the stage has no toggle of its own (a capture listener refuses the summary's click, tested at the click, so `question_cards.js` sets no `data-toggled`; the summary is out of the tab order, without its ▾): the stage's fold is the one (The stage's height) |
334
+ | A docked card answered or cancelled | right under its lead-in (`settle`), also after the turn's end; one without a lead-in at the history's end while the turn runs (after the turn's end it stays in the stage for the hand-off). Its own clamp is hidden while the lead-in is there (CSS). A card that stopped waiting goes there before a row placed after it in the same task (a replay) |
335
+ | check-in docked, nothing blocking (`.asks-live`) | the status row says the running call (`liveSlots().tool`, `foldedRowText` with `running`) after "waiting for you", then the turn's time; the dot keeps blinking; the live step's text streams under the card (`.ts-asks-text`), the row's word "writing" meanwhile (`stage_model.js phaseLabel`; `data-phase` stays "waiting for you"); at the turn's end `placeAnswer` empties it in the task that fills `.ts-answer`. The composer's delivery line reads blocking cards only (`stage_view.js phase`) |
336
+ | Live slots, the streaming answer, the end line, the queued slot | the stage, as before (no steps chip once opened: the steps line in the history says the count; a tick or a ref still reveals a step there); the hand-off appends what it still holds |
313
337
 
314
338
  A turn released without its end (a lost `turn_completed`, the next turn's start) drops its reasons (`stage_opened.js
315
339
  drop`) and leaves the rest; a late recap goes above an opened turn (`place`). The reload check's turn starts at its
316
340
  opening: its prompt, else a wake's note (`data-opening-id`) right before it, notes that reached the history mid-turn
317
341
  included (`wholeTurn`).
318
342
 
343
+ **Where the waiting-card state lives:** `stage_waiting.js`, one registry per stage keyed by a card's id (`q:<qid>`,
344
+ `c:<card id>`, `continue` for the old continue card), never by its node (`card.js drawCard` may swap a plugin card's
345
+ DIV for a DETAILS; a resync draws a question card anew). `place` adds (`arrive`: the kind, docked, approval);
346
+ `question_cards.js` settles through its `onSettled` hook (answered, cancelled, replaced, removed, the
347
+ continue card resolved or dropped), `cards_place.js upsertCard` settles a plugin card redrawn without its actions,
348
+ `turnEnded` the warn cards that leave, and `emptySlots` resets it. A second feed only settles: at each refresh
349
+ `stage_asking.js reconcileWaiting` drops an entry whose card is gone from the slots or waits no more (a card answered
350
+ outside `question_cards.js`, as the stage's unit tests do), and again before the fold, `pendingCard` and an opened row.
351
+ The registry's order is the stage's: blocking cards before the asking ones, arrival order within each, and `place`
352
+ puts a blocking card before check-in's in the slot, so a live page and a reload agree whatever the replay's order. The
353
+ asking form's card is the shown docked entry, the fold is the folded key (`stageCollapsed` compares keys); `opener`'s
354
+ lead-in Map is keyed the same way (a card drawn again with its key keeps its bubble: `relink`). `pendingCard`,
355
+ `asksCard`, the approval's cap, the summaries' tab order and the frame's waiting card read the registry.
356
+
319
357
  The stage says where the block is (`blockPlace`: cloud, answer, history; `data-block="history"` on the stage for
320
358
  `refs.js turnBlockOf`); the chip and a tick reveal the block or a step in the history. **Follow:** every row that reaches
321
359
  the history follows a reader who was at its end (`atHistoryEnd`, read before the change); a reason is read from its first
@@ -325,10 +363,12 @@ the pieces, in their task): the reasons go and each answered card's two-line cla
325
363
  row in view after a reason keeps its place when the history has room to scroll. The hand-off also waits while the pointer
326
364
  or a selection is in a reason (`stage_watch.js alsoWatched`). The reload check gets the turn from its opening (`wholeTurn`).
327
365
  **A reload or a resync** draws the same picture: `renderSessionView` replays the running turn (`data.replay`), in which
328
- the asking call has completed when its `question_requested` comes (`TurnAccumulator.join_events`), so the asking step is
329
- the last one whose last call is `ask_user_question` and whose reply no question card of this page took yet
330
- (`stage_view.js askingGen`, `asked`: a later card of that step never reuses it; live, the step whose call runs). The
331
- replayed card opens the turn as live; a question answered earlier in the turn settles under its reason with the
366
+ the asking call may have completed when its `question_requested` comes (`TurnAccumulator.join_events`: a question's
367
+ always, an approval's once it is answered), so the asking step is the last one whose last call is the one that asks
368
+ (any of its calls: `ask_user_question`; for an approval the tool it approves, the card's `data-tool`) and whose reply no
369
+ question card of this page took yet (`stage_asking.js askingGen`, `asked`; live, the step whose call runs). Live or
370
+ replayed, a step's reply is one card's lead-in: a second approval, or a question after an approval, of the same step has
371
+ none. The replayed card opens the turn as live; a question answered earlier in the turn settles under its reason with the
332
372
  replayed `question_answered`, and `releaseHeldWait` reads a pending card, not the `waiting` class the replay's one task
333
373
  hasn't refreshed yet.
334
374
 
@@ -347,8 +387,8 @@ counts. So:
347
387
  `init-spin .8s steps(16)`, 20 updates/s.
348
388
  - A blocking card (`.turn-stage.waiting`: a question, an approval, a delegate's relayed one, whose parent's turn blocks on
349
389
  it) stops them all, so movement means chi is working; `stage.spec.js`'s question scenario checks it, also after a
350
- reload. An asking card (check-in) has no `.waiting`, as its turn goes on: its dot keeps blinking. `.init-spin` is
351
- outside the stage (plugin setup only).
390
+ reload. An asking card (check-in) has no `.waiting`, as its turn goes on: docked, its dot keeps blinking and the status
391
+ row says the running call (A turn opened in the history). `.init-spin` is outside the stage (plugin setup only).
352
392
  - A one-shot fade on something that arrives many times a second is a recurring cost too. A live answer's new line
353
393
  appears at once: its fade alone was about a quarter of the streaming answer's total on the Studio (24-25% of a core to
354
394
  17-19%). The headline's `ts-fade` stays: turning it off saved nothing measurable.
@@ -431,7 +471,7 @@ reach another device or the server.
431
471
  | `chi_composer_collapsed` | `composer_fold.js` | the composer folded (a card's text field folds it for the time it has the focus on a narrow or touch screen, `composer_grip.js watchCardField`, on top of this and of the visit's state: neither is written) |
432
472
  | `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
433
473
  | `chi_strip_hidden` (`chi_zen`: the old name, read once) | `strip_hidden.js` | the session strip hidden |
434
- | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed |
474
+ | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed by hand (not written from the asking form, whose fold is that question's only) |
435
475
  | `chi_working_set_collapsed` | `working_set_rail.js` | the working set rail collapsed |
436
476
  | `chi_markdown_tip_dismissed` | `markdown_tip.js` | the markdown tip dismissed |
437
477
  | `chi_update_available_shown` | `version_notices.js` | update notices already shown |
data/docs/plugins.md CHANGED
@@ -1432,7 +1432,7 @@ block_renderers:
1432
1432
  fence has no language in the page and nothing draws, though a hint in the bundle's prompt section still reaches
1433
1433
  the model: say so in the bundle's README.
1434
1434
  - **Where it draws:** the owner's page only, in answers and closed steps. Participants' pages, cards, question
1435
- replies, the asking slot, delegate reports and the doc viewer show the source, and so does the terminal.
1435
+ replies, a lead-in bubble, delegate reports and the doc viewer show the source, and so does the terminal.
1436
1436
  - **The script's side:** the last file calls `chi.defineRenderer({ init, render })`. `init()` (optional, may be async)
1437
1437
  runs once; `render(source, theme)` returns the SVG as a string, or a promise of one. Throw to keep the block as
1438
1438
  source. `theme` is `{name, bg, panel, text, muted, border, accent, font}`, from the page's tokens. The script runs in
data/docs/releasing.md CHANGED
@@ -101,7 +101,9 @@ The agent does each step and stops where the user has to say yes.
101
101
  the release's session, bridge, cards, schedules and bundle state, and a
102
102
  session's input file, note, continue intent and stop marker and a bundle's
103
103
  install journal, under
104
- `spec/compat/X.Y.Z/`, and in its `wire/` what a worker answers its clients
104
+ `spec/compat/X.Y.Z/`. `SessionStores` supplies the cross-version
105
+ session-sidecar files; the stale `starting` marker is its documented
106
+ exception. The corpus also records in `wire/` what a worker answers its clients
105
107
  (`GET state`, `snapshot` idle and mid-turn, `tail`, and the session file:
106
108
  `script/release/compat_wire.rb` runs a real Engine and Bridge with a
107
109
  scripted kernel, in process); commit those samples with the release so the
@@ -109,11 +111,13 @@ The agent does each step and stops where the user has to say yes.
109
111
  recorded twice and must be equal: a bump that stops with "two recordings
110
112
  of wire/<file> differ at <JSON path>" found a field that changes per run
111
113
  (a new time, a random id, a measured number). Pin it in
112
- `CompatWire.normalise` (a `*_ms` key is pinned already; an id the script
113
- chooses is better pinned at its source), check `spec/release/compat_wire_spec.rb`,
114
- and run the bump again (nothing is written when it stops). The new VERSION may pass a
115
- compat shim's `remove_after` (`Compat::SHIMS`): `spec/compat_spec.rb` then
116
- names it, and the shim goes (or its date moves) before the release.
114
+ `CompatWire.normalise` (`*_ms`, `*_ms_sum` and `age` are pinned already, as is `last_turn.seconds`;
115
+ an id the script chooses is better pinned at its source), check `spec/release/compat_wire_spec.rb`,
116
+ and run the bump again (nothing is written when it stops). The new
117
+ VERSION may pass a compat shim's `remove_after` (`Compat::SHIMS`): once the
118
+ shim is also at least 14 days old (`added_on`, `Compat::MIN_AGE_DAYS`),
119
+ `spec/compat_spec.rb` names it, and the shim goes (or its date moves)
120
+ before the release.
117
121
  5. **Check.** Add all release files, including the new compatibility corpus, then run it:
118
122
  `git add -A && git commit -m "Release X.Y.Z"` and `bundle exec rake release:check`.
119
123
  Fix anything it finds in new commits and run it again.
data/docs/testing.md CHANGED
@@ -160,6 +160,10 @@ continue's `context/sessions/<id>/left.json`, stored approvals
160
160
  home (`home.json`). A corpus from
161
161
  0.164.0 or before has none of those and skips them; a later one must hold them. 0.165.0's and 0.166.0's approvals are a list, which chi no longer reads: the main read skips them and a spec there
162
162
  checks that the file is set aside. The corpus is always read from a copy, since a reader may rewrite or move what it reads.
163
+ A corpus after 0.168.0 also records every registered cross-version session store
164
+ (`SessionStores`): objective checks, tracks and proposal cards, delegates,
165
+ pending cards, reminders and parent overrides. The starting marker is the
166
+ documented stale-file exception.
163
167
  A corpus recorded after 0.165.0 also holds `wire/`: a worker's replies to `GET state`, `snapshot` (after a turn, and
164
168
  mid-turn with a prompt queued behind it) and `tail`, each `{route, status, body}`, and the session file it saved,
165
169
  recorded from a real Engine and Bridge (`script/release/compat_wire.rb`, `CompatWire`: a scripted kernel, read over the
@@ -212,6 +216,14 @@ Each level owns its own share; a detail is asserted once, at the lowest level th
212
216
  `UPDATE_TURN_DRAWINGS=1 bundle exec rspec spec/bridge/turn_drawings_spec.rb spec/web/session_show_drawings_spec.rb`
213
217
  rewrites their lines and show after an intended change) feed `turn_drawing_parity.test.js`, which draws each one
214
218
  live (the real stream handlers and stage) and from its drawing and compares the shapes with no allow list.
219
+ A turn cut mid-way while a card waits is `stage_cuts_parity.test.js`'s: the scenarios in `spec/shared/stage_cuts/`
220
+ (written from `spec/support/stage_cut_scenarios.rb` by `spec/web/session_show_cuts_spec.rb`, which folds the events
221
+ to each cut into a worker's snapshot and sends it through `Web::SessionShow`; `UPDATE_STAGE_CUTS=1` rewrites them)
222
+ are drawn live to the cut (question cards included) and reloaded from the cut's show, and the stage (`stageShape`:
223
+ its cards, the docked ones, the lead-in bubbles, the card in front, the one that shows and the pager, the fold) and
224
+ the history compared; a known difference is listed in its `EXPECTED`, by scenario, cut and field (one gone fails
225
+ too; none today), and on both pages `stage_waiting.js`'s cards agree with the slots, one docked card showing. `STAGE_CUTS_DEBUG=1` prints each cut's differences. Both
226
+ harnesses build their page with `spec/web/public/live_page.js`.
215
227
  `spec/shared/turn_ranges.json` (`UPDATE_TURN_RANGES=1 bundle exec rspec spec/bridge/turn_ranges_spec.rb`) pins
216
228
  `Bridge::TurnRanges`' turns, their record pairing, the items as the show stamps them and each turn's drawing end;
217
229
  timing.test.js checks `turnGroups` (stamps only) against it. Each scenario's
@@ -82,6 +82,7 @@ module Samagotchi
82
82
  input_epoch: "waiting-steer monitor",
83
83
  last_answer_message: "message snapshot under event lock",
84
84
  messages_checkpoint: "replace-only message copy",
85
+ objectives_view: "objectives snapshot under event lock",
85
86
  pending_question: "question-desk monitor",
86
87
  plugin_warning: "warning-state reader",
87
88
  recap: "recap-job reference",
@@ -372,14 +373,21 @@ module Samagotchi
372
373
  # against these messages; a reminder's, a Continue's or a note after it
373
374
  # hides it no more. Absent otherwise (no prompt with a turn id, a
374
375
  # rollback, an older worker's file).
376
+ # objectives: the view this worker announces, computed with the running
377
+ # conversation too; nil when there is no list, absent if reading it fails.
375
378
  # @return [Hash] {messages:, current_turn:, working_set:, queued:, queued_commands:, recap:, saved_recap:, continue_offer:, pending_question:,
376
- # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:, last_turn_drawing:}
379
+ # guardrail_warning:, plugin_warning:, init_tasks:, cards:, commands:, chi_version:, event_seq:, event_id:, last_turn_drawing:,
380
+ # objectives:}
377
381
  def snapshot
378
382
  @engine.synchronize_events do
379
383
  seq = @engine.event_count
380
384
  messages = @engine.messages_checkpoint
381
385
  drawing = last_turn_drawing(messages)
382
- {
386
+ objectives_unavailable = Object.new
387
+ objectives = Degrade.call(:bridge, "objectives_snapshot_failed", fallback: objectives_unavailable) do
388
+ @engine.objectives_view(@session_id, messages, state_dir: @state_dir)
389
+ end
390
+ snapshot = {
383
391
  messages: messages,
384
392
  current_turn: current_turn_with_generation,
385
393
  working_set: @accumulator.working_set,
@@ -398,6 +406,8 @@ module Samagotchi
398
406
  event_seq: seq,
399
407
  event_id: event_id(seq)
400
408
  }.merge(drawing ? { last_turn_drawing: drawing } : {})
409
+ snapshot[:objectives] = objectives unless objectives.equal?(objectives_unavailable)
410
+ snapshot
401
411
  end
402
412
  end
403
413
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.168.0
3
+ version: 0.169.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:
@@ -109,6 +109,9 @@ module Samagotchi
109
109
  HUMAN_IDS.include?(id) || HUMAN_PREFIXES.any? { |prefix| id.start_with?(prefix) }
110
110
  end
111
111
 
112
+ # Whether +client_id+ is the owner's input, not a participant's.
113
+ def owner?(client_id) = human?(client_id) && !review?(client_id)
114
+
112
115
  # The WakePolicy row a turn delivery from +client_id+ reads (deliver_turn's
113
116
  # two liveness checks): :send for a person (a web tab, a chi TUI, chi
114
117
  # send), :schedule, :plugin, :delegate or :participant for those
@@ -35,14 +35,14 @@ module Samagotchi
35
35
 
36
36
  module_function
37
37
 
38
- # Whether a message from +client_id+ may run as a session command: a
39
- # human's input (ClientId.human?: a web tab, a chi TUI, chi send, no
40
- # client) but a participant's (review:, refused on its own), and chi
41
- # answer's. A delegate, a child's report, a context or task wake, a
42
- # schedule, a reminder, a relay and a plugin may not (the bundles that
43
- # send, coordinator and skills, send prose).
38
+ # Whether a message from +client_id+ may run as a session command: the
39
+ # owner's input (ClientId.owner?: a web tab, a chi TUI, chi send, no
40
+ # client) and chi answer's. A participant (review:) is refused on its own.
41
+ # A delegate, a child's report, a context or task wake, a schedule, a
42
+ # reminder, a relay and a plugin may not (the bundles that send,
43
+ # coordinator and skills, send prose).
44
44
  def allowed?(client_id)
45
- ALLOWED_IDS.include?(client_id.to_s) || (ClientId.human?(client_id) && !ClientId.review?(client_id))
45
+ ALLOWED_IDS.include?(client_id.to_s) || ClientId.owner?(client_id)
46
46
  end
47
47
 
48
48
  # Whether +text+ would be a shell line (!<cmd>, !rollback) if a command: