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
@@ -175,17 +175,20 @@ row answers another session's id 404, so a new route is covered without a new ex
175
175
 
176
176
  | Route | Permission | A participant gets |
177
177
  |---|---|---|
178
- | `POST /api/sessions/:id/turn` | participant | a queued review turn of their own ([Turns](#turns)) |
178
+ | `POST /api/sessions/:id/turn` | participant | a turn of their own, or a line into their own running turn at its next step ([Turns](#turns)) |
179
+ | `POST /api/sessions/:id/cancel` | participant | their own running turn stopped, named by its `turn_id` ([Turns](#turns), Stopping their turn); no `turn_id`, the owner's turn or another participant's: 403 `not_permitted`, audited |
179
180
  | `POST /api/sessions/:id/turn/:eid/dequeue` | participant | their own queued turn back; another's (the owner's included) is 403 `not_permitted` |
180
181
  | `GET /api/sessions/resolve?id=` | participant | their session for its id or a prefix of it; anything else 404 as no session (never an ambiguity listing others) |
181
182
  | `POST /api/sessions/:id/wait/prepare`, `…/wait` | participant | only for a turn of theirs (`turn`: the enqueued id `POST …/turn` answered; else 403); `wait/prepare` answers only what that wait needs, `{session_id, baseline: {last_turn}, known_turn, live, last_turn_id}` (`last_turn_id` only when it is their turn), never the owner's cursor, message count, question or running turn; the answer trimmed to `{status, text, turn_id, outcome, cancel_reason}`, the reply's `text` through `ParticipantView.reply` (no thinking or tool call, redacted) and any other text (an error's exception, a failed turn's summary) `ParticipantMessages::TURN_FAILED`; a question or approval their turn raised, or their turn held by the owner's step-limit Continue offer ([Turns](#turns)), is `{status: "waiting_for_owner", detail: "waiting for the session's owner"}` with nothing of it; a revoke or the expiry ends a `wait` already running (401 `token_revoked` / `token_expired`), as it ends their stream |
182
- | `GET /` | participant | the participant page's shell (`participant.html`, its body's `data-session` and `data-name` the grant's; `participant.js` reads the rest through the two routes below). Never the owner's page, where every route would refuse them. |
183
- | `GET /api/sessions/:id/review` | participant | the page's data, filtered ([What a participant sees](#what-a-participant-sees)): `{viewer, session: {id, title, status}, subjects, participants, messages, current_turn, waiting_for_owner, queued: {mine, others}, last_event_id}`; a participant's `viewer` has their `usage` |
183
+ | `GET /` | participant | the main page (`index.html`) stamped for them (`App#participant_index_data`: their viewer, the grant's session and its title; [web-frontend.md](web-frontend.md#the-participants-page)); the page reads the rest through the show and the stream below |
184
184
  | `GET /api/sessions/:id/stream` | participant | the session's stream through `ParticipantStreamFilter` (only `from_seq` and `snapshot` reach the Bridge); it ends within a poll of a revoke or the expiry |
185
+ | `GET /api/sessions/:id/working_set` | participant | the working set's project files only (`ParticipantView.working_set`), with `running` |
186
+ | `POST /api/sessions/:id/images` | participant | their upload ([Participant images](#participant-images)): refused at their cap, past their attempts or quota, a bmp/tiff/heic; the reply `{file, name, width, height}` |
187
+ | `GET /api/sessions/:id/images/<name>` | participant | only an image they uploaded, `Cache-Control: no-store`; any other the 404 a missing file gets |
185
188
  | `GET /api/info` | participant | `{app, version, features}` (what `SessionAccess::Remote`'s check needs); not `pid`, `cwd`, `lan`, `installed` |
186
- | `GET /api/sessions/:id` | participant | their session only, whatever the query (`brief`, `cards`, `tail`, `timing`): `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
189
+ | `GET /api/sessions/:id` | participant | the main page's show in the owner's shape, every part filtered (`Web::ParticipantShow`, [The main page's show](#the-main-pages-show)); `?tail=1` and `?timing=1` filtered, `?cards=1` `{cards: []}`; `?brief=1` (what `SessionAccess::Remote#show` reads) `{session: {id, title, status, participants: [{id, name, kind, role}]}}` (the active ones). `title` is the session's own title, or "an untitled session": never `display_title`'s fallback, the first prompt's opening words |
187
190
 
188
- Everything else is `:owner`: `/answer`, `/command`, `/images` (an image a prompt carried is "[image]" on their page),
191
+ Everything else is `:owner`: `/answer`, `/command`,
189
192
  `/output` (the raw replies), the hub's `/api/events`, the cap route. Static assets (`/app.js`, …) are served to any principal past the gate: they are the public frontend.
190
193
 
191
194
  ## Turns
@@ -219,34 +222,65 @@ The worker decides it once at the turn's start (`GuardrailWiring#begin_turn` →
219
222
  (`participant_mode`), the boundary in `GuardrailWiring#checks`, `ToolRunner#unreached_group_refusal` (through
220
223
  the call's `TurnContext`: a review turn's boundary words the refusal; a pair turn gets the owner's), the
221
224
  mid-turn stop and the cap notice's words. The system prompt's line is the pair one once an active participant is
222
- stamped pair (`SessionParticipants#prompt_mode`); the participant page's `viewer.mode` is their own grant's
225
+ stamped pair (`SessionParticipants#prompt_mode`); their show's `viewer.mode` is their own grant's
223
226
  (`ParticipantMode.live_for`).
224
227
 
225
228
  A participant's `POST /api/sessions/:id/turn` (`ParticipantAccess#handle_participant_turn`) takes only the body's
226
- `prompt`. Three layers keep it a participant's own turn, inside its mode's boundary.
229
+ `prompt`, `delivery` and `images` (refs to their own uploads). Three layers keep it out of anyone else's turn, inside its mode's boundary: it goes into a
230
+ running turn only when that turn is theirs (at its next step, as the owner's message does), else it runs as a turn
231
+ of its own after it.
227
232
 
228
233
  **chi web.** The author is the grant's (`p:<id>`) and the client id `review:<id>` (`ClientId.review`: a human's
229
- input, origin `review`, source `participant`, labelled `review`); the body's `client_id`, `author`, `delivery`,
230
- `no_interrupt` and `images` never count. Refused before delivery, each audited:
234
+ input, origin `review`, source `participant`, labelled `review`); the body's `client_id`, `author` and
235
+ `no_interrupt` never count, its `images` only as refs to their own uploads, and its `delivery` only as `queue` or
236
+ next step (none: next step).
237
+ Refused before delivery, each audited:
231
238
 
232
239
  - a line starting `!` or `/` (after leading spaces): 403 `not_permitted`, so no shell line, session command or
233
240
  plugin command (v1);
234
- - images: 403 `not_permitted` (D10);
235
- - the grant's cap: 429 `cap_reached`, from the session's saved analytics (`SessionMetrics.saved_spend`).
236
-
237
- It goes out as `delivery: queue` (D6) with `pending_limit` = the grant's queue limit + 1 (the running one), never
241
+ - a cut (`delivery: cut`): 403 `not_permitted`: a participant's message never cuts a generation;
242
+ - the grant's cap: 429 `cap_reached`, from the session's saved analytics (`SessionMetrics.saved_spend`);
243
+ - a Revise draft (`revises: {turn_id}`, `ParticipantAccess#participant_revise_refusal`): a live worker without
244
+ `participant_revise` (`ReviseAnchor::PARTICIPANT_FEATURE`): 409 `revise_unavailable` in
245
+ `ParticipantMessages::REVISE_UNAVAILABLE` (never `ReviseAnchor::UNAVAILABLE`'s owner words, which
246
+ `#participant_turn_result` also maps for a worker without Revise at all); then `ParticipantRevise.refusal` over the
247
+ messages a live worker's snapshot has (`show_reads(...).raw_messages`: the file lags it mid-turn and just after a
248
+ turn's end), the file's only with no live worker. The turn must open with their prompt (the author chi web
249
+ stamped), have an answer and not be a Verify turn, else 403 `revise_not_own` (a missing turn id answers the same:
250
+ no existence oracle); every turn opening after it (`ParticipantRevise.opening?`: not the owner's `!` lines, which
251
+ they never see) must be theirs too, else 409 `revise_overtaken`. The audit line names the turn id (`revises`),
252
+ accepted or refused. The worker checks the same rule again at the stamp (`TurnStages::VerifyAnchors#revision_target`,
253
+ up to the Revise turn itself): one overtaken after the send silently becomes a plain turn (it runs and answers, no
254
+ stack, no notice to them or the owner), logged only as `revise_anchor_rejected`. `SessionManager::Delivery` also
255
+ refuses a participant's Revise to a live worker without `participant_revise` (`ReviseAnchor::PARTICIPANT_UNAVAILABLE`);
256
+ - images (`ParticipantImages#participant_images_refusal`, D10 lifted), in this order so nothing tells them which
257
+ files exist: not a list of at most 20 `{file, name}` refs (no `path`): 400 `bad_images` (`IMAGES_SHAPE`); any
258
+ ref they didn't upload (`ParticipantUploads`, read once), whether its file is there or not: 403 `not_permitted`;
259
+ only then the files (`ImageStore.check_refs`; one of theirs gone: 400 `bad_images`, `IMAGE_GONE`, the file named
260
+ only in the audit line); then the model: 422 `no_vision` in `ParticipantMessages::NO_VISION` when chi send's
261
+ check (`SendCommand.vision_answer`) says it takes no images (its full words, which name the model and `/model`,
262
+ only in the audit line's `detail`). The check reads the session's saved model, which can lag a live `/model`;
263
+ the Engine's own no-vision failure stays the backstop (their turn then fails, `TURN_FAILED`). Their refs go to
264
+ `deliver_turn` as `images:`; the turn's audit line counts them (`images`), accepted or refused by the worker.
265
+
266
+ It goes out with that delivery and `pending_limit` = the grant's queue limit + 1 (the running one), never
238
267
  into the owner's prompt history. The Bridge counts the author's running and queued turns under its event lock and
239
268
  answers 429 `too_many_turns` past it; with no worker, their waiting input files are counted, and the new one
240
269
  written, under the session dir's `participants.lock` (`SessionManager.deliver_turn`), so two at once can't both
241
- pass. The ack is 202 with
242
- `delivery: "queue"`; chi web keeps enqueued id → grant (the last 1000), which `dequeue` and `wait` check, falling
270
+ pass. The ack is 202 with the worker's
271
+ `delivery` (`queue`: a turn of its own; `next_step`: into their running turn, or the next turn of an idle worker; a
272
+ file, with no worker yet, reads `queue`); chi web keeps enqueued id → grant (the last 1000), which `dequeue` and `wait` check, falling
243
273
  back after a restart to the author the session's files keep (`ParticipantAccess#turn_author`): the waiting or claimed
244
274
  input file's, then `last_turn`'s or the settled record's (a refused turn's, `TurnCheckpoint.record_refused`; one whose
245
275
  worker died, `TurnCheckpoint.settle`, from its checkpoint mark's `author`).
246
276
 
247
- **The worker.** A participant's input file waits for its own turn whatever its delivery says
248
- (`SessionInbox.waits_for_turn_end?`), so a drain never merges it into another author's turn; the Bridge queues their
249
- `POST /turn` whatever it asks and never cuts for it; a participant's `/word` or `!line` is refused by the Bridge (403)
277
+ **The worker.** A participant's input file goes into a running turn only of its own author
278
+ (`SessionInbox.waits_for_turn_end?` / `turn_end_data?` with the running turn's participant author, which the worker
279
+ keeps from the turn's origin, `Worker#@turn_author`), so a drain never merges it into another author's turn, and a
280
+ participant's turn takes nobody else's line; the Bridge queues their `POST /turn` unless their own turn runs
281
+ (`Bridge#same_author_steer?`, under the event log) and never cuts for it; a line merged into their own turn first
282
+ gets the checks their queued turn gets at its start (below: the link, the cap), and one refused is dropped as that turn
283
+ would be, never merged; a participant's `/word` or `!line` is refused by the Bridge (403)
250
284
  and dropped from the worker's file path. A participant's turn goes only to a worker that names the `review_turns`
251
285
  feature (409 `review_unavailable` otherwise: it has no boundary; restart it), and never as a file while a worker
252
286
  that may be older holds the session (504 `worker_timeout`: send again). Before a queued participant turn starts, the
@@ -260,14 +294,29 @@ Verify turn's anchor (`VerifyAnchor`, `{turn_id, quote}`: a UUID and a quote of
260
294
  anchor, or a Verify fresh check, sent to a connected worker without it is refused 409 `verify_unavailable`
261
295
  (`VerifyAnchor::UNAVAILABLE`: "restart this session to use Verify"; `SessionManager` checks `worker_has?` before it
262
296
  delivers, on the features of the sidecar its client was made from), since an older worker would run it as a plain prompt. A session with no connected worker is not gated: the
263
- worker that starts for it is current. The participant page's session summary carries `verify_unavailable`
264
- (`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_review.rb`), and the page disables
297
+ worker that starts for it is current. A participant's show carries `session.verify_unavailable`
298
+ (`VerifyAnchor.worker_unavailable?`, from the worker's features; `participant_show.rb`), and the page disables
265
299
  Verify and Verify fresh with a "restart this session" hint.
266
- **The owner's input during a review turn queues.** While a participant's turn runs, the owner's input never merges
300
+ **Their message during their own turn goes in at its next step.** A worker names `participant_steer`
301
+ (`Delivery::OWN_TURN_STEER_FEATURE`) when it does; one without it queues every participant message, which is safe by
302
+ construction, so the feature only says what will really happen (chi web's ack, the page's delivery line). A
303
+ message sent while someone else's turn runs (the owner's, another participant's) queues behind it as their own turn.
304
+
305
+ **Stopping their turn.** `POST …/cancel` from a participant (`ParticipantAccess#participant_cancel`) names the
306
+ turn by its `turn_id`: chi web reads the live snapshot's running turn, requires it to be that turn and theirs (its
307
+ author the grant's), and asks the worker to cancel that turn id (`BridgeClient#cancel(turn_id:)`), which
308
+ `TurnState#cancel!` compares under its lock (409 `turn_mismatch`): a turn that ended and gave way to another
309
+ person's between the read and the cancel is never stopped. Without a `turn_id` (the Bridge would cancel whatever
310
+ runs), or for a turn not theirs, it is 403 `not_permitted`, audited. It is relayed only to a worker that names
311
+ `cancel_turn_id` (`Delivery::CANCEL_TURN_ID_FEATURE`): an older one ignores the id and would stop whatever runs, the
312
+ owner's next turn too, so it is 409 `cancel_unavailable` (`ParticipantMessages::CANCEL_UNAVAILABLE`), audited, until
313
+ the owner restarts it. The reason is the server's (`user`), never the body's.
314
+
315
+ **The owner's input during a participant's turn queues.** While a participant's turn runs, the owner's input never merges
267
316
  into it, from any client (the web, an attached TUI, `chi send` without `--queue`): the Bridge's `POST /turn` makes it
268
- `delivery: queue` whatever it asked, so it never steers or cuts (`TurnAccumulator#participant_turn?`, under the event
269
- lock), and its ack and `:turn_enqueued` say `queue`; the worker's drain takes no input file during a participant's
270
- turn (`Worker#pending_input_drain`, for a file the Bridge never saw), so each runs as the owner's own turn after it.
317
+ `delivery: queue` whatever it asked, so it never steers or cuts (`Bridge#same_author_steer?`, under the event
318
+ lock), and its ack and `:turn_enqueued` say `queue`; the worker's drain takes only the participant's own lines
319
+ during their turn (`Worker#pending_input_drain`, for a file the Bridge never saw), so each runs as the owner's own turn after it.
271
320
  The web's delivery line says so (`participantTurn`: Enter queues, no cut), the TUI prints its queued note, `chi send`
272
321
  says `(queued: runs after the current turn)` and its `--wait` waits for that turn (`ReplyWait::Target.message`). The owner's
273
322
  own turns steer as before. A step-limit Continue of a participant's turn (the owner answers it) continues as theirs: its origin (a `TurnOrigin`,
@@ -283,8 +332,9 @@ turns run in the order they came, each through the link and cap check above as i
283
332
  offer too and so releases them. A held file isn't queued input for the idle-exit policy (`Worker#input_pending?`): `/exit`
284
333
  and `chi sessions restart` name what waits, the offer (`continue_offered`), not a queued prompt. The offer lives in
285
334
  the worker: a worker that restarts has none, and runs them.
286
- Their `wait` always waits as for a queued turn of theirs, never for the next reply past a cursor (which could be
287
- anyone's); on a held turn it answers `waiting_for_owner` (`ReplyWait::Poll#held`), the offer asked before the wait
335
+ Their `wait` waits on their turn's id, never for the next reply past a cursor (which could be anyone's); a line
336
+ merged into their own running turn is followed into it by its merge record (`ReplyWait::Poll#follow_merge`: chi web
337
+ waits with `queued: false`, and the poll asks `turn_end_data?` as for its author's own turn); on a held turn it answers `waiting_for_owner` (`ReplyWait::Poll#held`), the offer asked before the wait
288
338
  began too. A chi client gets it through `--remote` with a participant's token in `remotes.yml`: `chi --remote NAME
289
339
  send --wait --turn T ID` reaches only participant routes (resolve, `wait/prepare` and `wait` on their turn), and
290
340
  `ParentReport` reads `waiting_for_owner` as its own status, exit 3 like a question, its detail
@@ -337,14 +387,54 @@ and `ctx.turn_author` names it ([hooks](../hooks.md#participants-turns)).
337
387
  participant once the grant is revoked or expired, so their stream closes within a poll of a revoke, including one
338
388
  made by `chi share revoke` in another process; the page then reads a 401 and shows the ended-link page.
339
389
 
390
+ ## Participant images
391
+
392
+ A participant attaches images to their own turns, as the owner does (`Viewer::PARTICIPANT_CAPABILITIES`' `images`:
393
+ the composer's paste, drop and image button). Review and pair grants alike: an image is input, not tool power; their
394
+ cap and the quota bound the cost (their turn's image tokens count to their cap by author, `SessionMetrics.saved_spend`;
395
+ later turns that re-send it in history are the owner's). `ImageStore` is content-addressed and keeps no metadata, so
396
+ who uploaded what is chi web's own record, `ParticipantUploads` (`participant_images.json`, [Files in the session
397
+ dir](#files-in-the-session-dir)), keyed by (file, grant): an owner's or another grant's identical image never
398
+ changes hands, and nothing else (a ref's `source` or `name`, the body's `author`, a message) grants a file.
399
+
400
+ **Upload** (`POST …/images`, `ParticipantImages#participant_image_upload`). Every upload counts as an attempt first,
401
+ before the cap check (a cap-refused one too). Refused, in order: their cap (429
402
+ `cap_reached`); past `Grants::MAX_IMAGE_ATTEMPTS` (80) uploads tried, refused ones counted (429
403
+ `too_many_image_attempts`); the owner's size cap (413 `too_large`); not an image chi stores (422 `bad_image`, in
404
+ `ParticipantMessages::BAD_IMAGE`, never `ImageStore`'s words: an image tool's output may name a host path); a
405
+ bmp, tiff or heic (422 `unsupported_image`, `IMAGE_TYPE`: sniffed with `ImageHeader.read` before `ingest`, so sips
406
+ or magick never converts their bytes from those formats); a header with no size, or over 40 megapixels or 12000 px
407
+ on a side (422 `image_too_big`, `IMAGE_TOO_BIG`; a phone's 4032×3024 passes); at `Grants::MAX_IMAGES` (40)
408
+ distinct files (429 `too_many_images`), unless the bytes are a file they own already (as stored unchanged,
409
+ `ImageStore.file_name_for`: a downscaled or re-encoded one is refused). A png/jpeg/gif/webp over the store's limits
410
+ is still downscaled by `ImageStore.fit`, so the tool does read their bytes: their conversions run one at a time
411
+ (`@participant_conversion_mutex`), and every conversion (anyone's) is killed past `ImageResizer::TIMEOUT` (60 s),
412
+ magick reading the sniffed format's coder under `-limit memory 256MiB -limit disk 1GiB -limit area 64MP`. An
413
+ upload that raises is audited (`internal_error`) before chi web's 500. The quota is checked under `@participant_images_mutex`, the
414
+ image ingested, then checked again and recorded under it (a race past it is refused there; the stored file may stay
415
+ in `images/`, unreachable to them). Each answer is an `image` audit line. The reply is the ref without `source`,
416
+ `mime` or `bytes`.
417
+
418
+ **Read back** (`GET …/images/<name>`, `SessionControl#handle_image`): a participant gets the file only when
419
+ `ParticipantUploads.owned?` says they uploaded it (the record proves they had the bytes), with `Cache-Control:
420
+ no-store`; anything else (the owner's, another grant's, a guess) is the same 404 a missing file gets, body and
421
+ headers alike. A revoked or expired grant is refused by the gate (401) as on every route.
422
+
423
+ **Their turn** takes only refs they uploaded ([Turns](#turns)); **what they see** is their own images as thumbnails
424
+ and anyone else's as "N images" ([What a participant sees](#what-a-participant-sees)). A corrupt or unreadable
425
+ record fails closed (every grant at quota, owning nothing, `Log.warn`); the owner can delete the file. Not covered:
426
+ a participant's `chi --remote … --image`; a failed turn of theirs gives the text back without its chips; Apply
427
+ doesn't mention a proposal's images.
428
+
340
429
  ## What a participant sees
341
430
 
342
- D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states.
431
+ D5 (a): prompts and replies with their authors, one-line tool rows, turn boundaries, the queue and waiting states
432
+ (and for what kind of thing: an approval, a question, a continue offer); for the main page (2026-11-13) also the
433
+ context's fill, the speed, each turn's time and the working set's file names.
343
434
  Not tool output or arguments, thinking, approvals' and questions' content, memories (text or names), context notes,
344
- plugin steers, automatic and delegate input, cards, command output, the working set (the user decided: not), cost
345
- (but their own usage, as their grant's exposure shows it),
346
- timing (durations, records: only the wall time `at` of each prompt, reply and turn start, for the page's gap lines
347
- and hover times, D6 of the session-time-gaps plan), host paths or the owner's prompt history. Every read a participant reaches goes through one module,
435
+ plugin steers, automatic and delegate input, cards, command output, the working set's memories and URLs, cost
436
+ (but their own usage, as their grant's exposure shows it), tokens and cost per model, guardrail judgments,
437
+ host paths or the owner's prompt history. Every read a participant reaches goes through one module,
348
438
  `Web::ParticipantView` (`lib/samagotchi/web/participant_view.rb`), whose every answer is a new hash built from named
349
439
  fields: a field added to an event later reaches nobody until it is named there.
350
440
 
@@ -354,12 +444,18 @@ fields: a field added to an event later reaches nobody until it is named there.
354
444
  type and turn id; `turn_completed` may also carry an allowlisted verdict card), `generation_chunk` (its visible
355
445
  `text` only; a thinking-only chunk is dropped),
356
446
  `generation_retrying` (`restarted`), `tool_call_started` (the tool's name, and for a file tool the path relative to
357
- the session's folder when it is inside it; never when that folder is home or `/`), `tool_call_completed` (its status word), and bare markers (the type
358
- alone) for the queue (`turn_enqueued`, `turn_dequeued`, `input_merged`), the owner's question, approval or Continue
359
- offer and their answers, and `snapshot` / `reset`: the page re-reads its review data at each (the latest read
360
- wins), draws no live event from a turn's start or end until that re-read, and then streams again from the data's
361
- `last_event_id`, so the events after its snapshot replay onto it; a read that fails (unreachable, a 5xx) keeps the
362
- page and is retried with a backoff under a "retrying" line. Refusals show the server's `detail`. Every other type,
447
+ the session's folder when it is inside it, as the owner's `ref`
448
+ `{kind: "file", path, line, end_line}`; never when that folder is home or `/`),
449
+ `tool_call_completed` (its status word); the viewer's **own** queue events in the owner's shape (`turn_enqueued`:
450
+ `enqueued_id`, `client_id`, `prompt`, `delivery`, `author`, their image refs; `turn_dequeued`: its `enqueued_id`, never
451
+ the client that took it back, the filter knowing their ids from the `turn_enqueued` it sent them and from
452
+ `ParticipantAccess#own_turn?`; `input_merged`: the count and origins of their own lines only), anyone else's bare;
453
+ `question_requested` and `continue_offered` with one derived `kind` (`.waiting_kind`: `approval` for a guardrail's
454
+ approval or a relayed one, `continue` for the step-limit question and every offer, `question` else); the main
455
+ page's numbers (`context_status`: usage's `estimated_pct` and token counts, `bucket`, a switch's
456
+ `context_window_tokens`, never `status`, the model-facing line; `generation_completed`: its `speed` only;
457
+ `working_set_updated`: `.working_set`, below); and bare markers (the type alone) for the answers to a question or
458
+ offer, and `snapshot` / `reset` (the page re-reads the session at a `reset`, as the owner's). Every other type,
363
459
  known (`DROPPED`) or new, is dropped. `spec/web/participant_view_spec.rb` scans every `type: :name` chi emits and
364
460
  every handler of the web's stream, and fails until a new one is named in `EVENTS` or `DROPPED`.
365
461
  - **The frames** (`Web::ParticipantStreamFilter`). The Bridge's chunks are cut into frames (`\r?\n\r?\n`, a partial
@@ -372,8 +468,8 @@ fields: a field added to an event later reaches nobody until it is named there.
372
468
  and never after a key word whose value is still to come, `Redact::OPEN_VALUE`; past 1600 with no such space,
373
469
  the line's last unbroken run and the rest of that line go out as one `[redacted]`). The owner's stream never
374
470
  goes through it (a byte pipe).
375
- - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`, `Web::MessageDisplay`; no turn drawings: the
376
- participant page rebuilds every turn from its messages, [bridge.md](bridge.md#turndrawings)): a user message only when it is a
471
+ - **The conversation** (`.messages`, over `App#messages_for_display(parts: true)`, `Web::MessageDisplay`; each finished
472
+ turn's drawing comes with the show, [The main page's show](#the-main-pages-show)): a user message only when it is a
377
473
  turn's prompt (it has the turn's id) or a person's merged line, and never one from a source that isn't a person's;
378
474
  so the owner's `!` line and its output, a delegate's report (a wake turn's too), and the notes chi adds (a declined
379
475
  Continue) never show. A turn no person sent (its record's `automatic`, `SessionMetrics`: a task's or a plugin's,
@@ -384,11 +480,55 @@ fields: a field added to an event later reaches nobody until it is named there.
384
480
  links group by `[data-turn-id]`). A raw loop's saved
385
481
  reply is cut before the display reads it (`.visible_raw`): what came before a bare `</think>` and everything from
386
482
  an unclosed thought or tool call on, which the display would only untag. Each
387
- prompt carries its author and an image count; replies their text and their step's tool rows. Notes, steers and
483
+ prompt carries its author and its images: the viewer's own as refs, anyone else's as a count
484
+ (`.image_refs`, below); replies their text and their step's tool rows. Notes, steers and
388
485
  empty-answer markers are dropped. The reply's
389
486
  `html` is rendered from the filtered text. The turn in progress (`.current_turn`, over the Bridge snapshot's) gives
390
487
  its start's fields, the text so far, its tool rows (with `running`) and `waiting_for_owner`.
488
+ - **Images** (`.image_refs`, on a message, a `turn_started` (so the running turn and the drawings) and a queued
489
+ entry): the viewer's own as `[{file, name, width, height}]`, only refs to a stored image (`ImageStore::REF_RE`;
490
+ any other dropped), never `source`, `bytes` or `mime`; anyone else's (the owner's, another participant's) as a
491
+ count, which the page draws as a muted "N images" line (`images.js thumbsTemplate`).
391
492
  - **The queue.** Their own queued turns (enqueued id and text, for Take back) and how many others wait.
493
+ - **The working set** (`.working_set`: the event and `GET …/working_set`): the file items inside the project, the
494
+ key made relative, with `state`, `tokens`, `ops`, `count`, `pinned`, and the set's `at`. Never a memory or URL
495
+ item, a path outside the project, `first_id`, `has_drop` or the pending `adds`.
496
+
497
+ ### The main page's show
498
+
499
+ `GET /api/sessions/:id` for a participant (`Web::ParticipantShow#participant_main_show`) is the main page's read
500
+ in the owner's shape (`SessionShow#owner_show`'s keys, from the same `show_reads`), every part through
501
+ `ParticipantView`: the one read their page makes (the participant page's `GET …/review` is gone).
502
+ `spec/web/participant_show_spec.rb` holds its parity: no key the owner's lacks but the
503
+ verdicts' (`fresh_running`, `session.verify_unavailable`).
504
+
505
+ - `messages` as the review read's (a turn's opening with its Revise stamp, `revises: {turn_id}`, and a Verify
506
+ turn's `verify: true`, a boolean, never the quote; `turn_completed` carries both too), stamped `drawn` (the turn whose drawing draws them); `turn_drawings` each
507
+ finished turn's drawing replayed event by event through `.event` (no cards), its start's prompt the filtered
508
+ messages' prompt of that turn, else none (an automatic turn, a wake, a delegate child's task: a made drawing's
509
+ start has no origin to say so), its end `.drawing_end` (type, turn id, duration, kept steps; never a failure's
510
+ words or a cancel's reason).
511
+ - `current_turn` (`.current_turn`: its start, text, tool rows, `waiting_for_owner` and `kind`, and the generation's
512
+ phase, `generation: {thinking_only, age}`, the delivery line's clock), `replay` (the join's events through
513
+ `.event`) and `queued` (the viewer's own entries, `.queued_entry`).
514
+ - `session`: id, participant title, status (idle when a worker says running with no turn), `bridge_up`,
515
+ `worker_features`, `verdicts` (filtered, as the review's) and `verify_unavailable`.
516
+ - `viewer`: `Web::Viewer`'s kind, id, name, `can` (`Viewer::PARTICIPANT_CAPABILITIES`: `send`, `stop_own_turn`,
517
+ `steer_own_turn`, `revise_own_turn`, and of the owner's `working_set` and `images`), `client_id` (`review:<grant id>`, which chi web gives their
518
+ turns), their `participant_kind`, `mode`, `cap_reached` and `usage`, `session_stopped`, and
519
+ `waiting_for_owner` (the kind the session waits on, the review read's rule: the running turn's question, a
520
+ Continue offer, a question between turns; nil when nothing).
521
+ - `timing` (`.timing`): the session's clock, each turn record's id, times, `duration_ms`, `decode_tps` and
522
+ `tps_source`, the running turn's start, the context's `used_tokens` and `window_tokens`. Never `tool_records`,
523
+ `active_tools`, `tokens`, `model_segments`, `memory_index`, `side`, nor a record's cost, tokens, model, author,
524
+ cancel reason or failure.
525
+ - Empty or nil: `history`, `commands`, `cards`, `init_tasks`, `queued_commands`, `recap`, `saved_recap`,
526
+ `continue_offer`, `pending_question`, `failed_turn`, the warnings, the markdown tips; `capabilities` never names
527
+ the editor.
528
+
529
+ The main page's `<body>` for a participant (`App#participant_index_data`): `version`, `session`,
530
+ `session-title`, the Annotate presets, the viewer and `working-set` (the owner's `web.working_set`); nothing of the
531
+ owner's machine (`home`, the folders, `project-*`, `back-*`).
392
532
  - **Their usage** (`viewer.usage`, `ParticipantReview#review_usage`, built from named fields by the exposure policy
393
533
  beside `ParticipantView`'s): the fields their grant's exposure shows
394
534
  ([Grants](#grants)); at `own`, `{spent, cap, queued, queue_limit, expires_at, model}`. `spent` is dollars: the saved
@@ -397,62 +537,33 @@ fields: a field added to an event later reaches nobody until it is named there.
397
537
  running turn shows before it is saved; the two are never added), read from the Bridge snapshot the review already
398
538
  reads. `queued` counts their own queued turns on the worker; `model` is the session's model as its host is sent it
399
539
  (the alias applied, no host name), live else saved, redacted. Every source reads the grant's own author: another
400
- participant's spend, the owner's and the session's totals never reach them (the red-team spec checks the body). The
401
- owner's participant view has none. The page draws it as one line in the comment box card's footer
402
- (`.composer-card-foot`, `usageItems`): "$0.42 of
403
- $2.00 cap · 1/3 queued · link: 6 d left · model", each with a title in words; warn colours from 80% of the cap, a
404
- full queue and a link's last day, "over" at the cap. The key hint is the line above the text field, wrapping to at most two (hidden on a
405
- phone). The usage is cut at the end (the model first); folded, it stays as the card's footer under the grip. It is redrawn at each re-read (a
406
- turn's end, a queued or taken-back comment), not while a turn streams.
540
+ participant's spend, the owner's and the session's totals never reach them (the red-team spec checks the body). Their
541
+ page draws the spend of the cap in its bottom row ("$0.42 of $2.00 used") and the link's end in its top row; it reads
542
+ the show again at each turn's end for it (the light re-reads carry no viewer).
407
543
  - **A fork's note.** A review fork (`chi review --from`, `/coordinate review`) starts with a note holding the source's
408
544
  recap and, from the coordinator, its handoff's body: a participant's review turn reads it like the rest of the
409
545
  history and may quote it (host paths, worktree names). That is by design (it is what the review is about); the
410
546
  owner chose to share it. Nothing else of the source comes along.
411
- - **The subjects** (`subjects`, a review session's: [docs/sessions.md](../sessions.md#review-sessions)): each
412
- `{path, text, truncated, html}` (`html` for markdown only, rendered from the redacted text), or `{path, missing:
413
- true}`. Every read checks the path again with `Guardrails::ReviewPaths` (a review turn's own check: under the
414
- project root, as given and as it resolves, never a secret file or chi's own), so a subject that became a symlink
415
- out of the project, or a session file edited to name `.env` or `../x`, is `missing`, never read. The text is cut
416
- at 256 KiB (`SUBJECT_BYTES`) and redacted. The real path is what is checked and opened, with `O_NOFOLLOW`: a
417
- symlink swapped in for the file itself after the check isn't read. That guards the last path component only: a
418
- folder on the way swapped for a symlink between the check and the open is still followed. Doing that takes write
419
- access to the project tree, the owner's, so it isn't a participant's way out; the next read checks again.
420
- `[]` for a session that isn't a review session. The page's title for a
421
- review session with no title of its own is `review of docs/plan.md` (`+N` for more), never the first prompt. The
422
- page shows them as the Plan: beside the thread on a wide screen (≥ 1100 px), a Plan | Thread toggle on a narrower
423
- one (the thread first, the composer pinned in both views); several subjects get a file picker. It re-reads them
424
- with the rest at every turn boundary and redraws the Plan only when they changed.
425
- - **Annotate** (`participant_annotate.js` over the owner's `annotate_bar.js`, styled by the shared
426
- `annotate_bar.css`). A participant who can comment selects text in the Plan or the thread and gets **Annotate** and
427
- the owner's `web.annotate_presets` (the participant shell's `data-annotate-presets`, as `index.html`'s), which append
428
- the quote to the comment box; nothing is sent. Labels: the Plan `From docs/plan.md:` (the subject shown), a comment
429
- `From Sam's comment:` / `From my earlier comment:` / `From the owner's message:` (the message's `data-by`,
430
- `data-name`), a reply none. Not annotatable: the reply still streaming, tool rows, waiting lines, gap lines, the
431
- header, the Plan's truncation note. No plugin pills (a review turn refuses `/` lines), and none of it in the owner's
432
- participant view (no presets in its shell) or while `can_comment` is false. On a touch screen the bar docks above the
433
- composer. A re-read keeps unchanged thread nodes and the Plan's (redrawn only on change), so a selection survives it.
434
- After the presets comes the built-in **Verify** (`verify_preset.js`, on the owner's page too): the quote, then
435
- "Verify this claim against the code. If this session's context doesn't settle it, say so. End with a line
436
- `Verdict: confirmed|refuted|unclear — <one-line reason>`." It fills the box like any preset and never sends.
437
- It is offered only where the Engine will take the claim (`verify_target.js`, the bar's `offeredPreset`): a
438
- selection in the final answer of an ended turn, the last answer bubble (the owner's `.bubble.output` that isn't a
439
- delegate report's reply; a participant's last `.p-msg.reply > .p-bubble`) between the turn's opening
440
- (`[data-turn-id]`) and the next one (or, on the owner's page, a context note: a wake turn opens with one; a
441
- participant's page gets a wake turn's opening as an automatic turn).
442
- Anywhere else (an earlier reply, a step, a prompt, a line merged mid-turn, the Plan, the running turn) the button
443
- is hidden and the bar keeps its other actions.
547
+ - **No file view.** A review session's subjects are not sent to a participant (their page has no file panel; they ask
548
+ chi to quote the part they need: [docs/sessions.md](../sessions.md#review-sessions)). Its title for a review session
549
+ with no title of its own is `review of docs/plan.md` (`+N` for more), never the first prompt.
550
+ - **Annotate** is the main page's (`annotate_bar.js`): a selection in the conversation offers **Annotate** and the
551
+ owner's `web.annotate_presets` (their `<body>`'s `data-annotate-presets`), which append the quote to the composer,
552
+ then the built-in **Verify** (`verify_preset.js`), offered only where the Engine takes the claim (`verify_target.js`
553
+ `OWNER_FINAL`: the final answer of an ended turn). No plugin pills (their show's `commands` is empty) and no Copy
554
+ link (no participants panel).
444
555
  - **Deep links to a claim** (`claim_link.js`). The owner selects text in a turn's answer, step or own message and
445
556
  Annotate's **Copy link** copies `<base>/#s=<id>&t=<turn8>&q=<quote>&p=<prefix>`: `base` the participants list's
446
557
  `link_base` (`ParticipantsAdmin#participant_base`: `web.public_url`'s origin, else the LAN address, else the Host),
447
558
  `t` the turn's id (its first 8 characters, the opening message's `data-turn-id`), `q` the selection's rendered
448
559
  text (whitespace collapsed, at most 200 characters) and `p` up to 32 characters before it in the same bubble, both
449
- URL-encoded by `URLSearchParams`. The text is read as the participant page shows it, by `Redact`'s own rules
560
+ URL-encoded by `URLSearchParams`. The text is read as a participant's page shows it, by `Redact`'s own rules
450
561
  mirrored in JS (`spec/fixtures/redact_cases.json` holds cases both sides must read the same): secret shapes
451
562
  `[redacted]`, whole even when the selection cuts into one, so a link never carries a secret; on the owner's page
452
563
  also the session's folder relative and the home folder `~` (the page's `data-home`). Not the owner's token (the
453
564
  page doesn't hold it). With no person (`kind: human`) active in the session, Copy link gives the owner's own form,
454
- `#/s/<id>?t=…&q=…&p=…`; the owner's page also opens the participant form. Opening one: the turn's bubbles
455
- (`.p-msg > .p-bubble`; the owner's answers, own messages and steps) are searched for the prefix and quote, then
565
+ `#/s/<id>?t=…&q=…&p=…`; the main page opens either form (`owner_claim.js`, a participant's page too: its one
566
+ session is the claim's). Opening one: the turn's bubbles (answers, own messages and steps) are searched for the prefix and quote, then
456
567
  the quote alone; the match's bubble is ringed (`.claim-hit`), a closed `<details>` around it opened, and the quote
457
568
  highlighted (the CSS Highlight API, `::highlight(chi-claim)`, where the browser has it). A quote not found (text
458
569
  that reads differently) rings the turn's opening. A turn no opening carries (a non-person turn the page doesn't
@@ -478,11 +589,10 @@ fields: a field added to an event later reaches nobody until it is named there.
478
589
  a valid `Verdict: confirmed|refuted|unclear — <reason>` appends a verdict to the session. Both pages place its card
479
590
  in the source turn, right after the bubble that holds the quote (the answer's, matched as a claim link reads the
480
591
  page; a quote not found yet goes at the turn's end), name its author, show the reason and quote, and link to the
481
- checking turn; a live completion adds the card without a reload. The participant view receives only the card fields through its allowlist, with the
592
+ checking turn; a live completion adds the card without a reload. A participant receives only the card fields through its allowlist, with the
482
593
  quote and reason redacted by the view's normal rules, never the full reply.
483
594
  - **Verify fresh** (`VerifyFresh`, `verdict_fresh.js`). An `unclear` card has a **Verify fresh** button (the owner's
484
- page always; a participant's while `can_comment`, i.e. while their grant could run a turn now; never in the owner's
485
- participant view). It is `POST /api/sessions/:id/verdicts/:check_turn_id/fresh` (`VerifyFreshRoutes`, `:owner`
595
+ page always; a participant's while their grant could run a turn now). It is `POST /api/sessions/:id/verdicts/:check_turn_id/fresh` (`VerifyFreshRoutes`, `:owner`
486
596
  and `:participant`), which starts a **fresh child**: a delegate child (`spawn_session`, `delegate: true`,
487
597
  `parent_id`) in the session's own folder (no worktree; a folder outside git too), on its model, whose first and only
488
598
  prompt is the claim, quoted, and "Verify this claim against the files here. You have none of the original session's
@@ -539,38 +649,34 @@ fields: a field added to an event later reaches nobody until it is named there.
539
649
  data). The card shows "Verifying fresh…" while the child runs (the summary's `verify_fresh` `{of, child_id}`,
540
650
  the review data's `fresh_running`, ids only) and the fresh verdict as a `fresh: ✅ confirmed — …` line on the same
541
651
  card. The original stays unclear, so the button stays, as **Verify fresh again**: each press is another paid
542
- child (a participant's against their cap). The participant view passes `fresh` and `of`, never the child's id (a
652
+ child (a participant's against their cap). `ParticipantView` passes `fresh` and `of`, never the child's id (a
543
653
  participant can't open it). No push
544
654
  for a fresh verdict yet (`Attention` pushes a verdict at a turn's end).
545
- - **The comment box** is the owner's composer (`composer_mount.js`, styled by the shared `composer.css`), mounted by
546
- `participant.js` with a subset on. It has: the height that grows with the text, the grip (drag to resize,
547
- double-click resets, click or drag down folds it; folded, the grip is the handle on the card's top edge and typing on the
548
- page opens it; Annotate opens it for the visit), the one-row layout of the owner's session composer (Send at its
549
- bottom right; a phone keeps it one row), and ↑/↓ over the participant's own comments: the ones the server took from
550
- this browser, in localStorage under `chi_review_history:<grant id>` (`review_history.js`; the grant id is the review
551
- data's `viewer.id`), at most 50, forgotten when the link ends (a 401 with chi's words), never the owner's `GET
552
- /api/history` (the owner may open a link in the same browser). It lacks: the `/` list and plugin pills (a review turn
553
- refuses `/` lines), the delivery line and its steer/queue choices (every comment is `POST …/turn`, a queued review
554
- turn; Alt+Enter is Enter), the model picker, Stop turn (the owner page's sits in its stage's status row; this
555
- page has no stage), and images: the chips are mounted but `imagesOff` refuses a
556
- paste, drop or pick with one line (the image button is hidden) until participant images land. The grip's floor and
557
- the fold are the owner page's keys (`chi_composer_floor`, `chi_composer_collapsed`), per browser. The owner's
558
- participant view mounts none: its dock stays hidden.
655
+ - **The composer** is the owner's, with what is theirs (`participant_main.js`; [web-frontend.md](web-frontend.md#the-participants-page)):
656
+ no `/` list or plugin pills, no ↑/↓ prompt history (the owner's `GET /api/history`), no model picker. Images go
657
+ in as the owner's do (paste, drop, the image button), within their quota ([Participant images](#participant-images));
658
+ a refused upload or send keeps the chips and toasts the server's words. While their own turn runs, a message goes in at its next step on a
659
+ worker with `participant_steer` (Alt+Enter queues it); while anyone else's runs, it goes in as their own turn after
660
+ it, and the delivery line says so. Stop turn shows on their own turn only. At their cap, in a stopped session, or
661
+ once their link ended, the composer gives way to one line (the cap's own words; the server's for the others) and
662
+ the conversation stays readable. A turn of theirs that waits for the owner (an approval, a question, a Continue
663
+ offer) gives the stage its waiting look with one line naming the kind, never a card.
559
664
  - **Redaction** (`Samagotchi::Redact`, `lib/samagotchi/redact.rb`, on every string that goes out): secret shapes (`sk-…`, `ghp_…`,
560
665
  `github_pat_…`, `AKIA…`, `xox…-`, `chp_…`, `AIza…`, `rk_live_…`, JWTs, Slack webhooks, private key blocks, an
561
666
  `Authorization:` header's value, any `…token` / `…api_key` / `…secret` / `…password` value, `=` or `:`, quoted or
562
667
  not, and a URL's password), the owner's web token, the session's folder (shown as relative paths) and the home
563
668
  folder (`~`). Defence in depth only: the allowlist is the control; a reply that paraphrases a secret isn't caught.
564
669
  - **Errors.** A participant's 404 and 500 carry fixed words, never an exception's (which may name a path); so does their `wait` on a turn that failed (`TURN_FAILED`).
565
- - **The owner's participant view.** The owner reaches the same reads: `/?as=participant#s=<id>` serves the participant
566
- page, `GET …/review` is always the filtered view, and `…/stream?as=participant` goes through the same filter, with
567
- no participant of their own (nothing `mine`, no composer). It is the server's filter, never a page imitating it.
670
+ - **No "view as a participant" for the owner.** The owner's `/?as=participant` view went with the participant page
671
+ (2026-11-13: every read on the main page would need the flag threaded through, and one missed read would show
672
+ unfiltered data in a view that claims to be the participant's).
568
673
 
569
- The red-team spec (`spec/participant_red_team_spec.rb`) reads the stream, the review, every show variant, `/output`
570
- and an image as a participant, with an approval, a question, a tool's call and output, thinking, a memory, a steer,
571
- a delegate report, an unknown event and host paths in the session, and finds none of them. It also names a secret,
572
- a file outside the project and a symlink out of it as subjects in the session file and reads none of them through
573
- `…/review`, and has `chi review` refuse each as a subject.
674
+ The red-team spec (`spec/participant_red_team_spec.rb`) reads the stream, every show variant, `/output` and images
675
+ they didn't upload (a guessed name, the owner's by its hash: 404) as a participant, sends another's image refs
676
+ (the owner's, another participant's, a guessed hash, a forged `source`: 403), with an approval, a question, a tool's call and output, thinking, a memory, a steer, a delegate
677
+ report, an unknown event and host paths in the session, and finds none of them. It also names a secret and a file
678
+ outside the project as subjects in a review session's file and reads none of them in the show, and has `chi review`
679
+ refuse each as a subject.
574
680
 
575
681
  ## The owner's controls
576
682
 
@@ -660,9 +766,15 @@ invited to has none: its files stay byte for byte as they were.
660
766
  it, or `(none)`, once a minute per grant and identity whatever the path; at most 256 refusals a minute are
661
767
  remembered and audited; or `what: command`, a participant's `/word` or
662
768
  `!line` the Bridge or the worker refused), `turn` (chars, `status: accepted` with its enqueued id, or `refused` with
663
- the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `review_unavailable`, `worker_timeout`), `cap` (a new
769
+ the reason: `not_permitted`, `too_many_turns`, `cap_reached`, `bad_images`, `no_vision` with its `detail`,
770
+ `review_unavailable`, `worker_timeout`; `images`: how many it carried), `image` (an upload: `status` accepted with
771
+ its `file` and `bytes`, or refused with the `reason`; never its name), `cap` (a new
664
772
  cap), `verify_fresh` (`of`, `status`, the child and mode or the reason). A Verify fresh child's participants' lines
665
773
  are its parent's. It goes with the session (retention, delete).
774
+ - **`participant_images.json`** (0600, `ParticipantUploads`, chi web only, beside `participants.json`, not under
775
+ `images/`: no route serves it): `{images: [{file, grant_id, at}], attempts: {<grant id>: n}}`, which image files
776
+ each grant uploaded and how many uploads each tried. Written whole (`AtomicFile`) under chi web's
777
+ `@participant_images_mutex`. It goes with the session.
666
778
  - **`participants.lock`** (0600, empty): the lock a participant's turn file is counted and written under when no
667
779
  worker runs ([Turns](#turns)).
668
780
 
@@ -698,13 +810,13 @@ means the owner: a turn or session with no author keys reads as the owner's and
698
810
  - **Records.** `last_turn.author`, the analytics turn record's `author`, `turn_enqueued`'s `author`, and the
699
811
  Bridge's queued rows. `turn_started` carries `author`, `author_name` and `participant_mode` on a participant's turn
700
812
  (the owner's carries none), and so does the Bridge's turn snapshot and its replay. The owner's history
701
- (`MessageDisplay`) and the participant view pass a participant message's `participant_mode` on: the author badge
702
- reads "Alex · review" or "Alex · pair" (a prompt drawn while queued takes its mode at `turn_started`).
813
+ (`MessageDisplay`) and `ParticipantView` pass a participant message's `participant_mode` on: the author badge
814
+ reads "Alex · review" or "Alex · pair" (to a participant, a pair turn is the name alone, the owner's prompt "owner") (a prompt drawn while queued takes its mode at `turn_started`).
703
815
  - **Side models' transcripts.** The idle recap's (`IdleRecap::TranscriptFilter`) leads a participant's message with
704
816
  "(a review contribution from Alex, a participant; not the user's words)", and `ctx.ask_model`'s names its paragraph
705
817
  `Participant`. The UI's readers name them: `TurnAccumulator.messages_of` gives a participant's prompt and merged
706
818
  input `author` and `author_name`, `Merge#event_fields` names the merged text's author when one participant wrote all
707
- of it, and the web's display messages carry both (the owner's badge, the participant page).
819
+ of it, and the web's display messages carry both (the badges).
708
820
  - **Through `--remote`.** `SessionAccess::WebBridgeClient#post_turn` takes `author:` and drops it: the remote chi web
709
821
  stamps the author from the token.
710
822
 
@@ -763,16 +875,20 @@ participant TUI in v1.
763
875
  - A participant reaches one session, through the `:participant` rows only; another session is a 404.
764
876
  - No token is written anywhere: the grants store has digests, the sidecar and the audit trail have neither.
765
877
  - Owner sessions are unchanged: no sidecar, no audit file, no key in the session JSON until a grant exists.
766
- - No participant reaches `/command`, `/answer`, `/output` or an image. Their turn's author and client id are stamped
878
+ - No participant reaches `/command`, `/answer`, `/output` or an image they didn't upload. Their turn's author and client id are stamped
767
879
  from the principal, never the body.
768
- - Everything a participant reads (their stream, the review, the show) is built by `ParticipantView` from named fields;
769
- an event type it doesn't name is dropped. The owner's participant view reads through the same filter.
770
- - A participant's turn is always a queued turn of its own (chi web, the Bridge, the inbox), never a command or a
880
+ - Everything a participant reads (their stream, the show) is built by `ParticipantView` from named fields;
881
+ an event type it doesn't name is dropped.
882
+ - A participant's message is a turn of its own, or goes into their own running turn at its next step (chi web, the
883
+ Bridge, the inbox), never into anyone else's turn and never a cut, a command or a
771
884
  shell line (chi web, the Bridge, the worker), and runs only on a worker with the review boundary: in a review
772
885
  session it denies everything but reads of the project; in a working session (a worker with `pair_turns`) the pair
773
886
  boundary denies secrets and owner-voiced tools, the owner's guardrails decide the rest, and every ask is the
774
887
  owner's, once only.
775
888
  - A participant's message is never sent without its review header, and never merged into the owner's message.
776
- - The owner's input is never merged into a participant's running turn (the Bridge queues it, the drain takes none):
777
- it runs as the owner's own turn after it.
889
+ - The owner's input is never merged into a participant's running turn (the Bridge queues it, the drain takes only
890
+ the participant's own lines): it runs as the owner's own turn after it.
891
+ - A participant stops only a turn they started, named by its id, which the worker compares under its lock.
892
+ - A participant revises only their own answer with only their own turns after it, checked by chi web at the send and
893
+ by the worker at the stamp (`ParticipantRevise`); their Revise never folds anyone else's turn.
778
894
  - A participant's turn never answers or drops the owner's Continue offer: it waits until the owner has.