samagotchi 0.170.0 → 0.171.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 (120) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/docs/architecture.md +2 -2
  4. data/docs/cli.md +37 -23
  5. data/docs/configuration.md +1 -1
  6. data/docs/internals/block-renderers.md +2 -2
  7. data/docs/internals/bridge.md +5 -4
  8. data/docs/internals/context-telemetry.md +13 -1
  9. data/docs/internals/engine.md +4 -4
  10. data/docs/internals/inline-widgets.md +1 -1
  11. data/docs/internals/objectives.md +1 -1
  12. data/docs/internals/participants.md +235 -119
  13. data/docs/internals/web-frontend.md +164 -33
  14. data/docs/internals/working-set.md +69 -5
  15. data/docs/releasing.md +2 -1
  16. data/docs/sessions.md +6 -3
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +8 -4
  18. data/lib/samagotchi/bridge.rb +39 -12
  19. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  20. data/lib/samagotchi/delivery.rb +13 -0
  21. data/lib/samagotchi/engine.rb +5 -1
  22. data/lib/samagotchi/event_fanout.rb +22 -1
  23. data/lib/samagotchi/grants.rb +5 -0
  24. data/lib/samagotchi/guardrails/review_paths.rb +2 -2
  25. data/lib/samagotchi/image_store.rb +51 -12
  26. data/lib/samagotchi/kernel_loop.rb +7 -1
  27. data/lib/samagotchi/llm/stray_tool_call.rb +7 -4
  28. data/lib/samagotchi/llm_context_stale.rb +4 -1
  29. data/lib/samagotchi/participant_messages.rb +39 -0
  30. data/lib/samagotchi/participant_revise.rb +61 -0
  31. data/lib/samagotchi/participant_uploads.rb +98 -0
  32. data/lib/samagotchi/reply_wait/poll.rb +5 -1
  33. data/lib/samagotchi/revise_anchor.rb +9 -0
  34. data/lib/samagotchi/send_command.rb +5 -0
  35. data/lib/samagotchi/session_inbox.rb +20 -12
  36. data/lib/samagotchi/session_manager/delivery.rb +7 -0
  37. data/lib/samagotchi/session_stores.rb +2 -0
  38. data/lib/samagotchi/tool_runner.rb +39 -5
  39. data/lib/samagotchi/tools/memory.rb +26 -7
  40. data/lib/samagotchi/turn_anchors.rb +0 -3
  41. data/lib/samagotchi/turn_stages/verify_anchors.rb +13 -1
  42. data/lib/samagotchi/verify_anchor.rb +6 -0
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +42 -77
  45. data/lib/samagotchi/web/participant_access.rb +129 -26
  46. data/lib/samagotchi/web/participant_images.rb +185 -0
  47. data/lib/samagotchi/web/participant_review.rb +19 -138
  48. data/lib/samagotchi/web/participant_show.rb +240 -0
  49. data/lib/samagotchi/web/participant_stream_filter.rb +25 -2
  50. data/lib/samagotchi/web/participant_view.rb +331 -24
  51. data/lib/samagotchi/web/public/annotate_bar.css +1 -2
  52. data/lib/samagotchi/web/public/annotate_bar.js +1 -2
  53. data/lib/samagotchi/web/public/app.js +124 -88
  54. data/lib/samagotchi/web/public/block_host.js +7 -4
  55. data/lib/samagotchi/web/public/bubbles.js +9 -1
  56. data/lib/samagotchi/web/public/cards_offscreen.js +69 -0
  57. data/lib/samagotchi/web/public/chip_popover.js +5 -4
  58. data/lib/samagotchi/web/public/claim_link.js +4 -4
  59. data/lib/samagotchi/web/public/composer.css +4 -6
  60. data/lib/samagotchi/web/public/composer_mode.js +11 -6
  61. data/lib/samagotchi/web/public/composer_mount.js +4 -4
  62. data/lib/samagotchi/web/public/ctx.js +12 -3
  63. data/lib/samagotchi/web/public/delivery_line.js +34 -5
  64. data/lib/samagotchi/web/public/history_shape.js +5 -1
  65. data/lib/samagotchi/web/public/images.js +7 -1
  66. data/lib/samagotchi/web/public/index.html +40 -4
  67. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  68. data/lib/samagotchi/web/public/markdown_place.js +5 -4
  69. data/lib/samagotchi/web/public/owner_claim.js +3 -3
  70. data/lib/samagotchi/web/public/owner_verify.js +0 -2
  71. data/lib/samagotchi/web/public/participant_bar.js +108 -0
  72. data/lib/samagotchi/web/public/participant_main.js +289 -0
  73. data/lib/samagotchi/web/public/participant_model.js +5 -292
  74. data/lib/samagotchi/web/public/participant_names.js +6 -1
  75. data/lib/samagotchi/web/public/participant_revise.js +66 -0
  76. data/lib/samagotchi/web/public/participants_panel.js +3 -12
  77. data/lib/samagotchi/web/public/question_cards.js +14 -3
  78. data/lib/samagotchi/web/public/refs.js +53 -36
  79. data/lib/samagotchi/web/public/reload_check.js +68 -11
  80. data/lib/samagotchi/web/public/revise_stack.js +21 -10
  81. data/lib/samagotchi/web/public/send_turn.js +24 -6
  82. data/lib/samagotchi/web/public/session_fields.js +1 -1
  83. data/lib/samagotchi/web/public/session_state.js +12 -4
  84. data/lib/samagotchi/web/public/session_sync.js +9 -2
  85. data/lib/samagotchi/web/public/session_view.js +17 -7
  86. data/lib/samagotchi/web/public/stage_asking.js +6 -4
  87. data/lib/samagotchi/web/public/stage_owner_wait.js +35 -0
  88. data/lib/samagotchi/web/public/stage_view.js +18 -4
  89. data/lib/samagotchi/web/public/stage_waiting.js +74 -6
  90. data/lib/samagotchi/web/public/storage.js +22 -0
  91. data/lib/samagotchi/web/public/stream_handlers.js +2 -1
  92. data/lib/samagotchi/web/public/tokens.css +1 -1
  93. data/lib/samagotchi/web/public/turn_block.js +3 -2
  94. data/lib/samagotchi/web/public/turn_events.js +15 -6
  95. data/lib/samagotchi/web/public/turn_html.js +9 -2
  96. data/lib/samagotchi/web/public/turn_range.js +1 -2
  97. data/lib/samagotchi/web/public/verify_target.js +2 -4
  98. data/lib/samagotchi/web/public/viewer.js +59 -0
  99. data/lib/samagotchi/web/public/viewer_self.js +53 -0
  100. data/lib/samagotchi/web/public/working_set.js +11 -5
  101. data/lib/samagotchi/web/public/working_set_view.js +53 -8
  102. data/lib/samagotchi/web/public/ws_cards.js +89 -0
  103. data/lib/samagotchi/web/public/ws_peek.js +106 -0
  104. data/lib/samagotchi/web/public/ws_preview.css +126 -0
  105. data/lib/samagotchi/web/public/ws_reader.js +319 -0
  106. data/lib/samagotchi/web/public/ws_timeline.js +200 -0
  107. data/lib/samagotchi/web/public/ws_way_in.js +25 -0
  108. data/lib/samagotchi/web/session_control.rb +39 -14
  109. data/lib/samagotchi/web/session_create.rb +1 -1
  110. data/lib/samagotchi/web/session_show.rb +75 -28
  111. data/lib/samagotchi/web/viewer.rb +70 -0
  112. data/lib/samagotchi/web/working_set_routes.rb +132 -0
  113. data/lib/samagotchi/worker.rb +19 -14
  114. data/lib/samagotchi/working_set.rb +260 -8
  115. metadata +20 -6
  116. data/lib/samagotchi/web/public/participant.html +0 -218
  117. data/lib/samagotchi/web/public/participant.js +0 -719
  118. data/lib/samagotchi/web/public/participant_annotate.js +0 -76
  119. data/lib/samagotchi/web/public/review_history.js +0 -72
  120. data/lib/samagotchi/web/public/tools_overview.js +0 -42
@@ -5,10 +5,11 @@ reaches the screen, and the guards every new piece should use. The modules' own
5
5
  does; this page says how they hang together. The server side is [architecture.md](../architecture.md) (Web);
6
6
  the working set's overlay is [working-set.md](working-set.md), participants are [participants.md](participants.md).
7
7
 
8
- No build step: `index.html` and `participant.html` load ES modules as they are, with lit-html (`vendor/`) as the
8
+ No build step: `index.html` loads ES modules as they are, with lit-html (`vendor/`) as the
9
9
  only library. Pure modules (`*_model.js`, `session_state.js`, `sessions_list.js`, `turn_range.js`, …) touch no DOM
10
10
  and have `node --test` specs under `spec/web/public/` (happy-dom for the DOM ones); the pages as a whole are covered
11
- by `npm run e2e` (`spec/e2e/`). `app.js` has no unit spec.
11
+ by `npm run e2e` (`spec/e2e/`). `app.js` has no unit spec of its own; `viewer_boot.test.js` boots the whole page
12
+ (`page_boot.js`, happy-dom, in a process of its own) for what each viewer gets (The viewer, below).
12
13
 
13
14
  **Every module has a size cap, `app.js` its own**: `npm run lint` fails past `max-lines` in `eslint.config.js`
14
15
  (counted as `wc -l` counts, comments and blank lines too). Every `public/*.js` module: 1200 (`samagotchi/module-size`,
@@ -28,23 +29,19 @@ page. A factory dep a new version stops passing is the same hazard the other way
28
29
  out of `send_turn.js`, `session_actions.js` and `lists_view.js`); a page loaded before keeps all its old modules
29
30
  until a reload, so the risk is a mixed cache, not an open tab. `answer_fold.js`, `details_motion.js`,
30
31
  `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 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
+ `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; it compares the turn's own cards and questions, dropping every entry CardStore attributes to another turn), `cards_offscreen.js` (the reload check's placer: `createCardsPlace`, `createBubbles` and `createQuestionCards` over a detached holder, every page hook inert - no stage, no scroll, no session id, no api, no storage - so a snapshot's cards land where a reload would put them without touching the page), `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, kept for this tab in `chi_asking_fold:<session>`; `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), `participant_main.js`, `participant_bar.js`, `viewer_self.js`, `stage_owner_wait.js`, `participant_revise.js` (a participant's page: The participant's page, below), `ws_timeline.js`, `ws_cards.js`, `ws_peek.js`, `ws_reader.js`, `ws_way_in.js` (what chi has of a working-set item: below, The working set's reader), `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
33
 
33
- ## Two pages, two models
34
+ ## One page, every viewer
34
35
 
35
36
  | Page | Entry | Model |
36
37
  |---|---|---|
37
- | **Owner page**: the start page and a session page | `index.html` → `app.js` | **Render once, then patch.** A session's open (`session_sync.js` `select` → `resync` → `session_view.js` `renderSessionView`) draws the history from `GET /api/sessions/:id`; from then on each stream event changes the DOM in place (`stream_handlers.js`). The DOM *is* the log. The start page is the same app with `store.selected` null. |
38
- | **Participant page**: a review or pair link | `participant.html` → `participant.js` | **Re-read at a turn boundary, then a keyed redraw.** It reads `GET …/review`, draws it, and streams; an event that ends or changes a turn (`reloads`) marks the data stale and the page reads it again. `drawnMessages` keeps a message's node while its signature is unchanged; a small `live` overlay (`participant_model.js applyLive`) draws the running turn on top. |
39
-
40
- The participant page is **deliberately separate**: it shares leaf modules with the owner page (the annotate bar,
41
- claim links, `composer_mount.js`, the verdict card and Verify fresh, `verify_target.js`, `verify_preset.js`,
42
- `auth_lost.js`, `token_fragment.js`, `format.js`) but none of the data layer, the stream handling or the message
43
- rendering. It has its own `api` (`{status, ok, body}`, never throws, its own ended / auth-lost handling) and its own
44
- `EventSource`. Its parallel copies of the owner's Verify and claim helpers (`verifyFreshState`, `verifyAnchorFor`,
45
- `showPendingClaim`, `CLAIM_ROOTS`; the owner's are `owner_verify.js` and `owner_claim.js`) differ on purpose in places (the source kind `reply` vs `answer`, the server's
46
- `fresh_running` vs `verify_fresh`, no home/cwd in its claim options). Don't converge them as a side effect of another
47
- change.
38
+ | **The main page**: the start page and a session page, the owner's and a participant's | `index.html` → `app.js` | **Render once, then patch.** A session's open (`session_sync.js` `select` → `resync` → `session_view.js` `renderSessionView`) draws the history from `GET /api/sessions/:id`; from then on each stream event changes the DOM in place (`stream_handlers.js`). The DOM *is* the log. The start page is the same app with `store.selected` null. |
39
+
40
+ A participant's link (a review or pair grant) opens the same page: chi web serves them `index.html` stamped with
41
+ their viewer (The viewer, below), their show and stream are filtered (`Web::ParticipantView`,
42
+ [participants.md](participants.md#what-a-participant-sees)), and what is theirs on the page is
43
+ `participant_main.js` (The participant's page, below). There is no second page: the participant page
44
+ (`participant.html`, `participant.js`, `GET …/review`) went with the participant main page's Phase C.
48
45
 
49
46
  ## Where state lives (owner page)
50
47
 
@@ -58,7 +55,7 @@ change.
58
55
  | **The history DOM** | the conversation as drawn (a running turn's reason bubble, `.bubble.said`, is live only: see A turn opened in the history): which turn a bubble belongs to (`data-turn-id`, `data-answer-turn`), what opens a turn (`TURN_PROMPT`, `bubbles.js`: a prompt or a wake's note, the same turns `Steer.turn_opening?` counts for a card's `turns_since`), an opening's id for a card's and the saved recap's stamp (`data-turn-id` on a prompt, `data-opening-id` on a wake's note or `/continue no`'s reason line; `card_stamps.js` places a snapshot's card after the stamped opening, `recap_stamp.js` the saved recap after the last turn it covers, each by its count (`turns_since`) only without one), kept command bubbles, which texts were shown, unnamed badges, folded answers and long prompts (`.folded`), Revise stamps (`data-revises` on a prompt; a Verify turn's `data-verify`) and the stacks' folded versions (`.stack-hidden`, `.stack-stub.open`); the switcher's version on view is `revise_stack.js`'s own (a copy `.stack-view` before the top answer, the top `.stack-swapped`; never stored) | `bubbles.js`, `cards_place.js`, `question_cards.js`, `turn_block.js`/`stage_view.js`, `answer_fold.js`, `revise_stack.js`, `stream_handlers.js` (a live `turn_completed`'s `revises` and `verify` onto its prompt) | read back as state: `turn_range.js turnElements` (one walker for a turn's elements), `keptCommandBubbles`, `seedSeenFromDom`, `nameUnnamedBadges`/`nameVerdictCards`, `answer_fold.js` and `revise_stack.js` (their observers decorate every path that fills `#history`; the stacks are `stacks()` of the stamps, read again on each change, never a store field), `history_reveal.js revealInHistory` (the one way a jump unfolds what hides its target) |
59
56
  | **A drawn turn** (`turn_drawings` of the full read) | every finished turn, as live drew it: `turnGroups` groups by the stamps only, a turn's stamped items (`drawn: <turn_id>`) one group, and `turnHistoryNodes` draws it by feeding its drawing's events to a live turn block offscreen (`turn_block.js drawnTurnNodes`: the block's own handlers, as the stage's), the stage's extras (a reminder, merged lines, a card that asks or warns) and end as live, then the answer, the merged lines and the steps' texts from their saved items (markdown, refs). Each of its top-level elements carries `data-drawn`: `cards_place.js` leaves that turn's in-turn CardStore entries (`of_turn`) to it, and a kept reminder bubble of that turn (`data-turn-id`) stays out after a resync. chi web sends a drawing for every finished turn (one made from its messages where live wrote none): `drawnGroup` pairs the record the end names (`end.record_id`, by place for a turn saved before turn ids), else by the turn's id, and the answer's `data-answer-turn` is the end's own `turn_id` only (a made-up id, `m…` or `…~N`, keys none: no Revise). Nothing is rebuilt from messages: an item that comes unstamped is a `plain` group drawn as a plain row of its text (its prompt or answer bubble, no steps), the running turn's as expected (`runningTurnId`, the show's `current_turn`: it has no line yet), any other a miss, marked `data-undrawn`, logged once per render (`console.warn`) and counted by the reload check (`undrawn`), never a throw; a drawing with no parts (a minimal line) draws its prompt, answer and end | chi web (the show) | `renderHistory`, the reload check's render |
60
57
  | **localStorage** (`storage.js` `readStored`/`writeStored`, guarded) | per-browser choices only | the module that owns the choice | read at start (see Storage keys) |
61
- | **SSE** | see Streams | the server | owner page: events go into the DOM directly; participant page: an event marks the data stale |
58
+ | **SSE** | see Streams | the server | events go into the DOM directly (a participant's through `participant_main.js wrapHandlers` first) |
62
59
 
63
60
  The objectives' strip keeps the list in its own closure (`objectives_strip.js`, seeded by `renderSessionView`, reset by
64
61
  `forgetSession`, updated by `objectives_updated`): [objectives.md](objectives.md#the-page).
@@ -203,6 +200,113 @@ the running turn's DOM) is cleared there, and the feature gates are primed for t
203
200
  (`pendingReviseAnchor`) is set only through `setReviseAnchor`, which redraws its chip and the Revise buttons (a
204
201
  redraw only it feeds, not a store field).
205
202
 
203
+ ## The viewer
204
+
205
+ Who looks at the main page and what they may do there is the **viewer** (`viewer.js`), chi web's `Web::Viewer`
206
+ (`lib/samagotchi/web/viewer.rb`: `Viewer.for(principal)` is the one place that decides it; the owner gets every
207
+ capability). chi web stamps it on `<body>` (`data-viewer-kind`, `data-viewer-can`: the names, space-separated;
208
+ `App#index_data_attributes`), and the owner's session show carries it as `viewer` next to `capabilities`. app.js
209
+ makes one `viewer` from the body at boot; `setCapabilities` passes a show's `viewer` to `viewer.update` (it narrows
210
+ the stamp, never widens it: the boot's controls were made from the stamp) and `forgetSession`'s
211
+ `setCapabilities(null, null)` resets it to the stamp. A page with no stamp may do nothing. It is not the show's
212
+ `capabilities` (what the page renders: markdown display, the editor) nor `refEditor()` (where a ref opens: the
213
+ editor and its URL, refs.js's actions): those two are per request.
214
+
215
+ **The null-control rule.** An owner-only control or call goes through `viewer.can(name)` where it is made, never at
216
+ each use: a control the viewer may not use is not created and not wired (`const x = viewer.can("…") ? createX(…) :
217
+ null`, then `x?.…` where it is read; a module's hook passed as `null`, as `stage_view.js onCancel`,
218
+ `working_set_view.js sendLine`, `composer_mount.js loadHistory`), and its static element in `index.html` stays hidden
219
+ (`viewer.control(el, name)` / `controls(els, name)`: the element when allowed, else hidden, marked `data-withheld`
220
+ and `null`; index.html's `[data-withheld]{display:none!important}` beats an element's own `display` rule, which a
221
+ bare `hidden` does not). A module the
222
+ page reads everywhere takes a flag instead (`question_cards.js answers`, `block_host.js renderers`,
223
+ `turn_block.js turnHistoryNodes`'s `apply`). No scattered `if (owner)`, and no 403 tolerated as a way of gating. The
224
+ gates are boot-time: a show's narrower `viewer` changes what `viewer.can` answers later, not the controls made.
225
+
226
+ | Capability | Controls | Calls |
227
+ |---|---|---|
228
+ | `sessions` | `#newBtn`, `#scope` (and `project_picker.js`), `#topStrip`, `#stripStatus`, `#stripGrip` (`strip_hidden.js`) | `refresh` (`GET /api/sessions`), `openSessionEvents` (`GET /api/events`), `/api/projects` |
229
+ | `models` | `#modelPick` (never shown without the list) | `startPage.loadModels` (`GET /api/models`) |
230
+ | `prompt_history` | ↑/↓ over the prompt history | `GET /api/history` (`loadHistory`) |
231
+ | `push` | `#notifyBtn` (`notifications.js` not made; the tab title is set directly) | `/api/push*`, presence, the second `/api/events` stream |
232
+ | `context` | the context chips (`context_chips.js`) | `…/context*` |
233
+ | `working_set` | the stage's working set (`working_set_view.js`) | `GET …/working_set` |
234
+ | `working_set_items` | the list rows' `▸` peek, the reader over the stage, a tool row's `timeline`, the ref menu's "What chi has" (`ws_reader.js createWorkingSetPreview`: not made without it, so `working_set_view.js` gets `peek: null`, `ws_way_in.js` stays off, `installRefClicks` gets `whatChiHas: null`) | `GET …/working_set/item` (on a tap only) |
235
+ | `participants_admin` | the participants chip and panel, Copy link's participant target (`claimTarget`) | `…/participants*` |
236
+ | `rename` | `#titleRenameBtn` | (`/title` goes as a session command) |
237
+ | `images` | `#attachBtn`, `#attachInput`; paste and drop toast `IMAGES_NOT_HERE` | `POST …/images` |
238
+ | `session_controls` | `#infoContinueBtn`, `#infoArchiveBtn`, `#infoStopBtn`, `#infoDeleteBtn`, `#infoMoreBtn` (the ⋯ sheet), the ⚙ chip and panel, the `/` list, the stage's task stop, the working set's drop/undrop | `POST …/command` (`composer.js`: without it a `/` or `!` line goes as a turn, and the worker decides), `…/stop`, `…/holds*`, `…/tasks/:id/stop`, `…/reload_check`, continue → (`POST /api/sessions`) |
239
+ | `revise` | the answers' Revise (`revise_stack.js`) | (a Revise goes with `POST …/turn`) |
240
+ | `revise_own_turn` (a participant's) | Revise on the answers to their own messages (`participant_revise.js`, `revise_stack.js mayRevise`); the stacks for everyone | (`POST …/turn` with `revises`, checked by chi web) |
241
+ | `apply` | the Apply row (`turn_block.js`, `placeApply`, its click) | `GET …/apply` |
242
+ | `answer_cards` | question, approval and continue cards (`question_cards.js`), System 1's "wrong" | `…/answer`, `…/question/dismiss`, `…/guardrails/wrong` |
243
+ | `render` | | `POST /api/render` (`markdown_place.js`: step texts, a question's kept reply) |
244
+ | `block_renderers` | a bundle's drawn blocks (the core `chi-ui` drawer stays) | `/api/block_renderers*` |
245
+ | `stop_any_turn` | the stage's Stop turn (`onCancel`) | `POST …/cancel` |
246
+ | `steer_any_turn` | none: the delivery line reads a participant's own `steer_own_turn` instead (`viewerTurn`) | |
247
+
248
+ A participant's `can` (`Viewer::PARTICIPANT_CAPABILITIES`, in their show's `viewer` and on `<body>`) names
249
+ capabilities of their own, not the owner's: `send` (the composer), `stop_own_turn` (Stop turn on a turn they
250
+ started, `POST …/cancel` with its `turn_id`), `steer_own_turn` (a message during their own turn goes in at its next
251
+ step, on a worker with `participant_steer`), `revise_own_turn` (Revise on the answer to their own message); and of the owner's only `working_set` and `images`, which their routes
252
+ serve filtered: the working set's project files ([participants.md](participants.md#the-main-pages-show)); their own
253
+ uploads within their cap and quota, read back only by them ([participants.md](participants.md#participant-images)).
254
+ Their messages carry their own images as refs (thumbnails) and anyone else's as a count, which `images.js
255
+ thumbsTemplate` draws as a muted "N images" line (`.thumbs-hidden`). `stop_any_turn` / `stop_own_turn` both make the
256
+ stage's Stop turn (`onCancel`); a participant's shows on their own turn only (`stage_view.js canCancel`).
257
+
258
+ The owner routes (`App::ROUTES` rows marked `:owner`) the page never calls without one of the above:
259
+ `/api/update*` and `…/restart` (`version_notices.js`: its versions come only from the hub stream, `sessions`);
260
+ `…/archive`, `…/unarchive`, `DELETE …/:id` (the bar's buttons, `session_controls`; the strip and the all view,
261
+ `sessions`); `POST /api/sessions` from the start page (a participant's page opens the grant's session and never the start
262
+ page) and the
263
+ participants panel's review start (`participants_admin`); `GET …/images/:file` (a message's image refs; a
264
+ participant's messages carry refs only for their own uploads, the one image a participant's route serves them); the objectives strip's posts and the plugin cards' actions (`…/command`:
265
+ drawn only from the show's `objectives` / `cards` and their stream events, which `ParticipantView` drops). Not called
266
+ by the page at all: `/api/sessions/summaries`, `…/output`, `…/notes`, `…/wake`, `…/worker`, `…/exit`, `…/recap`,
267
+ `…/stats`, `…/children/counts`, `…/turns/:id/records`, `/api/participants`. `viewer_boot.test.js` pins the table:
268
+ with every capability today's calls and controls, with none no owner call and no owner control, each one alone,
269
+ and a participant's (their show, the working set and their stream, nothing else).
270
+
271
+ ## The participant's page
272
+
273
+ What is a participant's on the main page is `participant_main.js`, made by app.js only when `viewer.kind()` is
274
+ `participant` (`participant`, null on the owner's page) and wired in a few lines; its pure views are
275
+ `participant_bar.js`. The page stays the owner's model (render once, then patch); the participant's parts only read:
276
+
277
+ - **One session.** `<body data-session>` (the grant's) is the session `selectFromHash` opens, whatever the hash
278
+ names (an old `#s=ID` link, a claim link's `#s=…&t=…&q=…`, which `owner_claim.js` still shows); `allViewOpen` is
279
+ never true; there is no start page.
280
+ - **Who they are** (`viewer_self.js`): set from each show's `viewer` (`setCapabilities` → `participant.shown`),
281
+ since `<body>` names no ids. `turn_events.js isOwn` counts their client id (`review:<grant>`, chi web's for every
282
+ tab of theirs) as the tab's own; a `turn_started` with `mine` starts their queued bubble (`bubbles.js
283
+ queuedOwnBubble`: no origin names its enqueued id to them). `turn_html.js userBubbleTemplate` gives their echo
284
+ their badge and the owner's prompts the "owner" label; `participant_names.js authorBadgeText` reads a pair turn as
285
+ the name alone for them ("· review" stays).
286
+ - **The rows.** The top row: a plain logo (`.plain-brand`, not `#newBtn`), their name ("Alex · invited") and, on a
287
+ desktop, when the link ends. The bottom row (`#participantBar`) in `#infoText`'s place, drawn by the `infoBar`
288
+ redraw (`updateInfoBar` returns early): status, ctx %, the last speed (the turn's time while one runs), their
289
+ spend of the cap, and on a desktop what their turns may do (`RULES_TEXT`). Never the owner's cost, the model,
290
+ memories or a path.
291
+ - **The running turn.** `wrapHandlers` wraps `stream_handlers.js`'s handlers: `turn_started`'s author and `mine`
292
+ decide Stop turn (`canCancel`) and the delivery line (`deliveryTurn` → `delivery_line.js viewerTurn`: their own
293
+ turn on a `participant_steer` worker takes the message at its next step, Alt+Enter queues; anyone else's queues it
294
+ as their own turn) and the placeholder (`composer_mode.js steers`); `question_requested` / `continue_offered`
295
+ (their `kind` alone) set the stage's waiting look (`stage_owner_wait.js`, through `stage_view.js ownerWait`), no
296
+ card; a turn's end reads the show again for their usage (the light re-reads carry no viewer).
297
+ - **The closed composer** (`#participantClosed`): their cap reached (the show's `cap_reached`, or a send refused
298
+ `cap_reached`: the show read again), a send refused `session_stopped`, or a link that ended (`token_revoked`,
299
+ `token_expired`: a refused send's words, or `linkEnded`, `onAuthLost`'s handler, from a brief read's 401). The
300
+ refusals show the server's `detail` as it is (`send_turn.js closesOn`); every other refusal is the toast.
301
+ - **Revise** (`participant_revise.js`): the stacks are installed for `revise_own_turn` too, so their page folds every
302
+ stack (the owner's included); the Revise button is `installReviseStacks`' `mayRevise`, `participantMayRevise`:
303
+ `mayReviseOwn` mirrors `ParticipantRevise` on `historyTurns`' list (their prompt's `data-author` or this tab's
304
+ echo, an answer, no `data-verify`, and only their own turns after it), false while the composer is closed
305
+ (`participant.closed()`; `onClosed` redraws on a flip). The gate is the `participant_revise` feature, its words
306
+ `PARTICIPANT_REVISE_UNAVAILABLE`, the one page copy of `ParticipantMessages::REVISE_UNAVAILABLE`. A send refused
307
+ `revise_not_own` / `revise_overtaken` is a failed line with the server's `detail`, the text and the chip kept
308
+ (`send_turn.js`).
309
+
206
310
  ## Render idioms
207
311
 
208
312
  Three, for one job. Pick by what the piece is:
@@ -211,7 +315,7 @@ Three, for one job. Pick by what the piece is:
211
315
  |---|---|---|
212
316
  | **lit `render(template, keptContainer)`** | most components (~26): the info bar, chips and their popovers, panels, the init row, the working set, `start_page.js` | anything redrawn while the user may be in it: lit keeps the nodes, so focus, typed text, an open `<details>` and a tooltip survive. **The default for new UI.** |
213
317
  | **lit as a string builder**: `turn_html.js elementOf(template)`, `lists_view.js` (a fresh fragment, then `replaceChildren`) | history bubbles appended once; the session lists | a node built once and then owned by the DOM (history), or a list redrawn whole. `lists_view.js` throws away lit's diffing and binds card listeners per draw, with an `entering` tracker for its animations |
214
- | **hand-built DOM** (`createElement`) | `question_cards.js`, `verdict_card.js`, `verdict_fresh.js`, `participant.js` (`el()`) and a few small ones | older pieces; don't add new ones this way |
318
+ | **hand-built DOM** (`createElement`) | `question_cards.js`, `verdict_card.js`, `verdict_fresh.js` and a few small ones | older pieces; don't add new ones this way |
215
319
 
216
320
  Listeners: every factory is a page-lifetime singleton, so `addEventListener` without a remove is fine; a per-render
217
321
  listener lands on a fresh node. A popover goes through `chip_popover.js createChipPopover` (its own outside-click,
@@ -268,11 +372,11 @@ so a Safari tab's toolbar never covers the composer). index.html's `:root` names
268
372
  (`env(safe-area-inset-*)`, 0 on a desktop), and only these read them: main's padding (16px, or the inset where it is
269
373
  wider; its bottom is 16px less than `--safe-b`, since the session's card floats 16px above main's bottom; a phone's
270
374
  session panel has no bottom padding and runs to the screen's bottom, its card at `--dock-bottom`, 16px or the
271
- indicator's line), a bottom
272
- sheet's offset (`chip_popover.js place`: 8px above the indicator, or a keyboard's offset alone), the update sheet, the
273
- toast's fallback place (in a session `--toast-bottom` already sits it above the card), the lightbox's padding and the
274
- signed-out banner (`auth_lost.js`, `env()` itself: the participant page has no `--safe-*`). A new fixed or bottom-anchored
275
- piece reads `--safe-*`, never `env()`: the e2e set the names by hand (`web.spec.js`, a portrait phone and a landscape one),
375
+ indicator's line), a bottom sheet's offset (`chip_popover.js place`: 8px above the indicator, or a keyboard's offset
376
+ alone), the update sheet, the toast's fallback place (in a session `--toast-bottom` already sits it above the card), the
377
+ lightbox's padding and the signed-out banner (`auth_lost.js`, `env()` itself).
378
+ On phones, the standalone app also has a 12px body inset, keeping its content below iOS 26's blurred band under the
379
+ clock. A new fixed or bottom-anchored piece reads `--safe-*`, never `env()`: the e2e set the names by hand (`web.spec.js`, a portrait phone and a landscape one),
276
380
  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
381
  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`).
278
382
 
@@ -298,8 +402,13 @@ edge, with **a shrink for each asking form and after an answer** (`forgetContent
298
402
  frame (`frameTarget` returns null) and an unfold refits to what shows. Three things decide it, kept apart: the user's
299
403
  **choice** (the ▾ by hand, in memory, written to `chi_stage_collapsed`; never read back from storage to decide, so
300
404
  another tab's write doesn't apply live), **revealed** (a ref's "show in turn" opened it for this turn; the choice stays)
301
- and **the docked card's fold** (the waiting card the user folded, by its key). In the asking form only that fold counts:
302
- the ▾ and a tap on the folded row set or clear it and write nothing (another blocking card that comes to wait meanwhile
405
+ and **the docked card's fold** (the waiting card the user folded, by its key, held in `chi_asking_fold:<session>`
406
+ in sessionStorage: this tab's own, so a reload or a resync mid-turn draws it folded as it was — `stage_waiting.js`
407
+ writes it on the fold and reads it back in `trackFold` once the replay has drawn the card; it goes when the card is
408
+ answered or cancelled (`question_cards.js`), when the fold drops, when the turn hands off, and when the page opens
409
+ another session (`session_sync.js`); `reset` clears the memory only, so the replay can take the fold back). In the
410
+ asking form only that fold counts: the ▾ and a tap on the folded row set or clear it and write nothing to
411
+ `chi_stage_collapsed` (another blocking card that comes to wait meanwhile
303
412
  drops it, so the stage opens on the front card), so a card always arrives open (in the card's task, before
304
413
  `question_cards.js focusWhenIdle` focuses it) and a new one never matches an old fold; the same card drawn again with its
305
414
  id keeps it. The fold reads the registry checked against the slots first (`reconcileWaiting`), so a card answered in
@@ -423,6 +532,25 @@ writes only when its text changes. Expanded, it is a right-aligned span in the n
423
532
  the timing line in the status row (`.ts-stall-head`). Hooks: `.turn-stage.stalled` and `data-stall` (1, 2, net) on
424
533
  the stage's root. The dot keeps blinking meanwhile.
425
534
 
535
+ ## The working set's reader
536
+
537
+ What chi has of one working-set item ([working-set.md](working-set.md#timeline-what-the-model-has-of-one-item)):
538
+ `ws_timeline.js` is the pure part (the server's events into cards newest first, reads merged by state, and between the same edits, into line
539
+ slices, the file strip, the peek's pick, the step tags from the item's `runs`: working_set.js `itemsOfTurn` keeps each
540
+ row's `tool_id` with its place), `ws_cards.js` the cards' templates (`diff_view.js` for diffs, `.read-line` for lines),
541
+ `ws_peek.js` the popover's peek (one row at a time; working_set_view.js draws it under the row and grows the popover to
542
+ `PEEK_WIDTH`, a phone's sheet to `SHEET_TALL`: `chip_popover.js` reads `width`/`sheetShare` as functions per
543
+ placing), `ws_reader.js` the reader and the wiring (`createWorkingSetPreview`: the peek, the reader, `attach(workingSet)`
544
+ for the set, its jumps and its list, and `whatChiHas` for refs.js), `ws_way_in.js` a tool row's `timeline` button (a
545
+ flag turn_html.js reads; on only once the reader is attached). State: the reader's own closure (the open item, its
546
+ answer, the tab), never `store.current`. The reader is a `section` in `#center` over the history and the composer
547
+ (both `inert` while it is open, so their scroll place stays; a phone's is `position: fixed`). Each item answer goes
548
+ through `createLatest` keyed by the session and the item; a new server snapshot (`onServer`) asks again while it is
549
+ open (the route's messages lag a running turn). Leaving the session closes it and the peek: `session_view.js
550
+ forgetSession` (a switch, the start page, + New, a deleted or archived session) calls `wsPreview.reset()`, so
551
+ `#center` is never left inert, whatever the next session's GET does; close gives focus back to where it was at the
552
+ open (not after a jump or a reset). Styles: `ws_preview.css`.
553
+
426
554
  ## Stale answers
427
555
 
428
556
  The page switches sessions, reopens a popover or asks again while a fetch is out. An answer for the old ask must be
@@ -434,11 +562,11 @@ dropped. **`latest.js`** is the guard:
434
562
  ask out stale (a close, a reset). For a list that is re-read.
435
563
 
436
564
  On it: `context_chips.js`, `project_picker.js`, `participants_panel.js` (`load`), `cards_place.js`
437
- (`refreshCardBodies`), `stream_handlers.js` (`answer_display`), `working_set_view.js` (`fetchServer`), the
565
+ (`refreshCardBodies`), `stream_handlers.js` (`answer_display`), `working_set_view.js` (`fetchServer`), `ws_peek.js` and `ws_reader.js` (an item's timeline), the
438
566
  turn-end re-reads (`session_view.js` `readTurnEnd`, app.js's `refreshTimingFromSession`, `mergeTurnTiming`) and app.js's init-row timers. Still by hand
439
567
  (the id decides more than "drop it"): `session_sync.js` `resync`/`retryResync` (the re-read budget, `resyncAgain`), the stream's
440
568
  close, `session_actions.js` (continue/review open another session on purpose), `send_turn.js` (a failure for another
441
- session becomes a toast), and `participant.js` (its own `loadSeq`, the separate page). A new await in a component
569
+ session becomes a toast). A new await in a component
442
570
  uses `latest.js`.
443
571
 
444
572
  ## API clients
@@ -448,7 +576,8 @@ uses `latest.js`.
448
576
  HTML) or a fetch that fails on a sign-in redirect reports to `auth_lost.js` (`onAuthLost`, set by app.js).
449
577
  `cantText(what, error)` is the toast.
450
578
  - `method_api.js methodApi(method, path, body)`: the same, as `notifications.js` and `push_devices.js` call it.
451
- - `participant.js api`: its own (see above).
579
+ - A participant's page uses `data.js api` too: a 401 (their link ended) goes to `participant_main.js linkEnded`
580
+ (`onAuthLost` set again by app.js), never the owner's signed-out banner.
452
581
 
453
582
  ## Streams
454
583
 
@@ -457,10 +586,10 @@ uses `latest.js`.
457
586
  | The hub, `GET /api/events` (`data.js openEvents`) | app.js `openSessionEvents` (scoped by `?dir`) | `snapshot` then `session` / `session_gone` → `applySessionEvent` → `allSessions`; EventSource reconnects itself (a fresh snapshot); `onClosed` (a proxy sign-in on reconnect): probe, reopen after `EVENTS_REOPEN_MS` |
458
587
  | A second hub stream, unscoped | `notifications.js` (`allEvents`) on a project's page | the Home Screen app's device tidy-up across projects |
459
588
  | The open session, `GET /api/sessions/:id/stream?from_seq=` (`data.js openStream`) | `session_sync.js` `startStream` from `store.current.streamSeq` | events → `stream_handlers.js`. It closes itself on an error; the page resyncs (`resync`: a fresh render and a new stream from its cursor), once at once when it had opened, else after `STREAM_RETRY_MS`. Meanwhile a turn the stage holds reads "reconnecting…" (Quiet, under Frames), and the Bridge's `: ping` is invisible to the page. A re-read that can't reach chi web keeps the history it drew (`resync_reach.js`): the session bar's status chip says "retrying" while `retryResync` tries, "offline" once the tries ran out (`store.current.reach`; short words that fit the chip's reserved width, the full words in its tooltip), but only while no turn runs: during a turn the stage's "reconnecting…" is the one signal (on a phone the bar can hide its status) and the chip keeps the turn's word, and any hub event for the session then re-reads it (`followSelectedSession`); the next drawn re-read clears it. Only a first load (select's "Loading…") shows the error in the history |
460
- | The participant stream | `participant.js connect` | listens for `STREAM_EVENTS`, which is `Web::ParticipantView::EVENTS` (checked by `spec/web/participant_view_spec.rb`); reopened from fresh data when CLOSED |
461
589
 
462
- A new event type for the owner page is a handler in `stream_handlers.js` (the participant view spec makes it decide
463
- `EVENTS` or `DROPPED`); for the participant page also an entry in `STREAM_EVENTS`.
590
+ A new event type is a handler in `stream_handlers.js`; the participant view spec makes it decide `EVENTS` or `DROPPED`,
591
+ and checks that every type in `EVENTS` has a handler there or in `participant_main.js` (a participant's page opens
592
+ the same stream, filtered).
464
593
 
465
594
  ## Storage keys
466
595
 
@@ -474,13 +603,15 @@ reach another device or the server.
474
603
  | `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) |
475
604
  | `chi_composer_floor` | `composer_grip.js`, `composer_mount.js` | the composer's dragged height |
476
605
  | `chi_strip_hidden` (`chi_zen`: the old name, read once) | `strip_hidden.js` | the session strip hidden |
477
- | `chi_stage_collapsed` | `stage_view.js` | the stage collapsed by hand (not written from the asking form, whose fold is that question's only) |
606
+ | `chi_stage_collapsed` | `stage_asking.js` | the stage collapsed by hand (not written from the asking form, whose fold is that question's only) |
478
607
  | `chi_working_set_collapsed` | `working_set_rail.js` | the working set rail collapsed |
479
608
  | `chi_markdown_tip_dismissed` | `markdown_tip.js` | the markdown tip dismissed |
480
609
  | `chi_update_available_shown` | `version_notices.js` | update notices already shown |
481
610
  | `chi_notify`, `chi_push_device` | `notifications.js` (an injected storage) | the bell's state, this device's push id |
482
611
  | `chi_answer_fold:<session>` | `answer_fold.js`, `revise_stack.js` | the answers and long prompts (`<turn id>:prompt`) folded by hand, and the Revise stubs unfolded by hand (`stub:<turn id>`) (swept with the session list) |
483
- | `chi_review_history:<grant>`, `chi_review_history_session:<session>` | `review_history.js` (participant page) | a participant's composer history |
484
612
 
485
- sessionStorage: `chi_client_id` (`turn_events.js`: this tab's client id, kept over a reload mid-turn) and
613
+ sessionStorage (this tab only, `storage.js` `readSession`/`writeSession`/`removeSession`, guarded the same way):
614
+ `chi_client_id` (`turn_events.js`: this tab's client id, kept over a reload mid-turn),
615
+ `chi_asking_fold:<session>` (`stage_waiting.js`: the docked card the user folded and how many cards waited at the
616
+ fold, so a reload or a resync mid-turn draws the asking form as they left it), and
486
617
  `chi.authReloadAt` (`auth_lost.js`: one automatic reload a minute).
@@ -13,6 +13,17 @@ after the call: `ToolRun#stamp`, `{path:, mtime_ns:, size:}`. The path is the on
13
13
  path against the session's folder, `File.expand_path`, no realpath). A stat that fails, or that isn't a file's (a
14
14
  directory), gives no stamp; nor does any other tool.
15
15
 
16
+ An ok `memory_read` is stamped differently: `Tools::MemoryRead.call` returns a `Tools::MemoryRead::Result`, a String
17
+ with `reads` besides it - per name read, the file that answered and the model overlay appended to it, if any (KernelLoop
18
+ hands those to the runner as `result[:memory_reads]`). Its stamp is
19
+ `{memories: {name => {path:, mtime_ns:, size:, overlay: {…}}}}`, one entry per name, with no `mtime_ns` at its top.
20
+ That is compat N-1 in both directions: a reader that only knows file stamps (`WorkingSet.stamp_of` needs a top-level
21
+ `mtime_ns`) sees nothing in it, and a session saved before memory stamps has no stamp at all - either way its memories
22
+ are never stale. The name it read from, and its overlay, are what "changed since" is measured against, so a system
23
+ memory the model read is stale when its file or the overlay read with it moved, even if a project memory of the same
24
+ name exists. A `read`, `edit` or `write` of a memory's file is still a file item: the page has no memory row for
25
+ that path, so counting it as the memory comes with the page builds that map it too.
26
+
16
27
  Both `ToolResponse` shapes save the stamps as `tool_stamps`, one per run, read by the run's place
17
28
  (`ToolIds::Ref#run`):
18
29
  - `ToolResponse.joined` (the native loop's batch entry): `runs.map(&:stamp)`, only when one is non-nil, like
@@ -84,7 +95,8 @@ saved the build doesn't pair the sent conversation at all (the view returns the
84
95
  dropped` and the page offers its undrop.
85
96
 
86
97
  **Stale** is a file item whose latest run left a stamp, and whose file now differs from that stamp in mtime or size,
87
- or is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
98
+ or is gone. A memory item goes the same way: the entry's file differs from the stamped one, or the model overlay
99
+ stamped with it does, or either is gone. No stamp, live. A forgotten item stays forgotten whatever its file did.
88
100
 
89
101
  **An item's outputs.** `WorkingSet.outputs_of(conversation, kind:, key:, strategy:, cwd:)` answers an item's
90
102
  content outputs (`WorkingSet::Output`: `id`, the tool_response entry's `index`, `derived` for a legacy entry's id,
@@ -93,8 +105,9 @@ content outputs (`WorkingSet::Output`: `id`, the tool_response entry's `index`,
93
105
  when it has outputs and none is `sent`. An edit-only item, a failed run's file or an unknown key has none; a
94
106
  `memory_read` of several names is an output of each name.
95
107
 
96
- **Cost.** Two `paired_runs` walks, one view build and one stat per file item. A 500-message session builds in about
97
- 10 ms on an M5 Ultra and 75 ms on a CI runner; `spec/working_set_spec.rb` only guards against a blow-up (< 400 ms).
108
+ **Cost.** Two `paired_runs` walks, one view build, a stat per file item and one per memory stamp. A 500-message
109
+ session builds in about 10 ms on an M5 Ultra and 75 ms on a CI runner; `spec/working_set_spec.rb` only guards
110
+ against a blow-up (< 400 ms).
98
111
 
99
112
  ## Delivery
100
113
 
@@ -122,10 +135,11 @@ a canceled continue's restore, a failed turn's) announces the set at once (`anno
122
135
  smaller set reaches the page then, not at the next request. It goes out whatever the web's flag;
123
136
  the Bridge (`ClientEvent.project`) passes it on unchanged, and the attached TUI ignores it.
124
137
 
125
- **The route.** `GET /api/sessions/:id/working_set` (`Web::App#handle_working_set`) answers the snapshot's `to_h`
138
+ **The route.** `GET /api/sessions/:id/working_set` (`Web::WorkingSetRoutes#handle_working_set`, mixed into App) answers the snapshot's `to_h`
126
139
  and `running`. With a live worker it builds from the worker's snapshot messages (as `handle_show` (`Web::SessionShow`) reads them, keys
127
140
  symbolized) and the worker's `llm_context` summary. Without one it builds from the session file and its saved chip
128
- (`SessionSetting::LLM_CONTEXT`). The folder is the session's `working_directory`. `running` is true when the
141
+ (`SessionSetting::LLM_CONTEXT`); both through `working_set_source` (a `WorkingSetRoutes::Source`), which the item's
142
+ timeline route builds from too (Timeline, below). The folder is the session's `working_directory`. `running` is true when the
129
143
  snapshot has a turn in progress (`current_turn`). Between turns, a newer worker's `idle_working_set` is used directly;
130
144
  an older worker without that key still gets the file-build fallback.
131
145
 
@@ -138,6 +152,11 @@ event's, so the page takes it (below). Before the turn's first event the turn's
138
152
  Engine emits only a set that differs from the last one), and the GET builds from the messages as between turns; so
139
153
  does one from an older worker without the field, its answer then behind the turn's events, which the page keeps.
140
154
 
155
+ **An item's timeline while a turn runs** (`GET …/working_set/item`, [Timeline](#timeline-what-the-model-has-of-one-item))
156
+ has no such event to answer: it always builds from the snapshot's messages, so mid-turn it lags the turn's runs
157
+ (the running turn's reads of the item are not in it yet). Accepted for v1: the page asks again on each
158
+ `:working_set_updated` while its reader or a peek is open, and the turn's end brings the messages up to date.
159
+
141
160
  ## Steering: drop and undrop
142
161
 
143
162
  `/working-set drop KEY…` and `undrop KEY…` (`WorkingSetCommand`, `SessionCommands#working_set`; the web's list posts
@@ -214,6 +233,51 @@ projection), on both loops. It costs one model step; a model may skip it (the no
214
233
  A running turn's set has none (the note was sent when it started), so the Bridge snapshot's `working_set` and the
215
234
  GET's `running` answer leave it out.
216
235
 
236
+ ## Timeline: what the model has of one item
237
+
238
+ **The projection.** `WorkingSet.timeline_of(conversation, kind:, key:, strategy:, cwd:)` answers a
239
+ `WorkingSet::Timeline` (`kind`, `key`, `events`): every run of the item in conversation order, edits and writes too,
240
+ from the same `all_touches` walk `build` and `outputs_of` use, so the set, the steer and the timeline never disagree
241
+ on which runs are an item's. Each `WorkingSet::Event` is `tool_id` (ToolIds; `derived` for a legacy entry's), the
242
+ tool_response entry's `index`, the global `step` (`LLMContextStale::PairedRun#step`: the page's step tags are its own,
243
+ see below), `op` (`read`, `edit`, `write`, `memory_read`, `memory_write`, `fetch`), `sent` (the projection's
244
+ `still_sent?`; an edit or a write always), `dropped` (the user's applied drop), `forgotten` (not sent, not by a drop),
245
+ and what it holds:
246
+ - a read: `lines` (`WorkingSet::Lines`: `first`, `last`, `whole`, `partial`) from `LLMContextStale.file_run`, never
247
+ re-parsed from the arguments: `last` is counted off the text, so an `end_line` past the file is clamped (its
248
+ `[read: end_line …]` note is taken off the `text`); a whole read over `read.truncate_at_bytes` (the head/tail
249
+ preview) and a ToolRunner-cut output are `partial`, shown as stored, kept out of the file strip and the merging.
250
+ `text`: the stored output without its `[read]` lead;
251
+ - an edit or a write: `diff`, the saved `tool_diffs` (never re-diffed): `ToolResponse.joined` saves an array (one per
252
+ run, nil for none), `.single` the bare Hash, read as `MessageParts` does. nil for a no-op or a failed snapshot, maybe
253
+ `truncated`, `{skipped}` or `{error}`; `diff_view.js diffTemplate` draws them all;
254
+ - a memory_read: the whole output (a read of several names is not split: `shared_with` names the others, as
255
+ `Output#shared_with` does), and `memory` (`WorkingSet::MemoryPart`, from the stamp): `scope` (a file under
256
+ `memories/projects/<key>/` is the project's, else the system's), `path`, `overlay_path`, and for a read of one name
257
+ the appended overlay's text (`overlay`, taken off `text`); nil for a session saved before memory stamps;
258
+ - a memory_write: the text it wrote (its `content` argument); a fetch: the output.
259
+
260
+ Merging overlapping reads, the newest-first order and the step tags are the page's (presentation): the page keeps
261
+ each row's `tool_id` with its place (`working_set.js` items' `runs`), so an event's tag is its row's (`t3·s14`) and
262
+ "go to the call" is the step tags' jump; a legacy row (no id) falls back to the server's global `step`. The file
263
+ strip's length is the largest last line any read knows (a whole read's is the file's then), not a stat. The page:
264
+ [web-frontend.md](web-frontend.md#the-working-sets-reader).
265
+
266
+ **Now.** `WorkingSet.memory_now(conversation, key:, …)` answers what the memory's latest `memory_read` would answer
267
+ now (`WorkingSet::Now`: `text`, `diff`, `gone`): each name that read read, from the file its stamp names, with the
268
+ overlay stamped with it appended as `Tools::MemoryRead.call` appends it (`Model-specific guidance (<key>):`, the key
269
+ off the overlay's file name), and a missing name's `Error:` tail kept; `diff` is `TextDiff.unified` of what the read
270
+ sent against that (`EditPreview`'s shape, so `diff_view.js` draws it; an empty `text` is "no change"). `gone` when a
271
+ file is; nil without a stamp.
272
+
273
+ **The route.** `GET /api/sessions/:id/working_set/item?kind=&key=` (`WorkingSetRoutes#handle_working_set_item`,
274
+ `:owner, :session`: a participant gets 403 from `not_permitted_for`) answers `timeline.to_h`, and with `&now=1` for a
275
+ memory `now:` too. Unlike the set's route it never answers a worker's precomputed set: it builds from
276
+ `working_set_source` (the worker's snapshot messages, else the file's; the session's strategy and folder). Its texts
277
+ are whole (about 1.7k lines at the most seen in real sessions): the tool limits bound them (`read.truncate_at_bytes`,
278
+ the ToolRunner cap), so the page asks this route, never a row's `output` (a 2000-char `ToolOutputCut::WEB_PREVIEW_CHARS`
279
+ preview for a long one). 400 without a known `kind` and a `key`.
280
+
217
281
  ## The page's overlay
218
282
 
219
283
  The page keeps its row-derived items and their `at` ordinals (the step tags' jump path), and overlays the server's
data/docs/releasing.md CHANGED
@@ -119,7 +119,8 @@ The agent does each step and stops where the user has to say yes.
119
119
  `spec/compat_spec.rb` names it, and the shim goes (or its date moves)
120
120
  before the release. The bump also refreshes `spec/shared/compat_wire/*.json`
121
121
  by running `UPDATE_COMPAT_WIRE=1 bundle exec rspec spec/compat/wire_readers_spec.rb`;
122
- commit those fixtures with the release corpus.
122
+ commit those fixtures with the release corpus. If that refresh fails, the
123
+ bump is already written; follow its rerun command before reviewing or committing.
123
124
  5. **Check.** Add all release files, including the new compatibility corpus, then run it:
124
125
  `git add -A && git commit -m "Release X.Y.Z"` and `bundle exec rake release:check`.
125
126
  Fix anything it finds in new commits and run it again.