# 0.39.X Final Creature Comforts — Presence, Viewer Role, Message Composer, Finish Notifications

**Status:** DRAFT for Rosson review (2026-06-10)
**Target:** the final 0.39.X release (after 0.39.45 comms-reliability)
**Author:** pod-leader
**Review copy:** `docs/final-creature-comforts-review.html` (rendered walkthrough with UI-location callouts)
**Related:** GH #12 (terminal perf — §8), the 0.39.43 multi-client arbitration work (active-viewer claims), 0.39.45 verified injection (`workspace_msg.rs::inject_payload_verified`), #617/#629 connect-users + roles
**Distinct from:** `.k2so/prds/0.39.x-creature-comforts.md` (May 2026, the post-0.39.0 workweek polish cycle — different scope)

---

## 0. Principles that bind every feature here

1. **Daemon-first.** Every piece of canonical state (who is connected, what role they
   hold, what's queued for a session) lives in the daemon. Clients render truth and
   send gestures. Headless test (`curl /cli/...`) before calling anything done.
2. **Events over polling.** All push surfaces ride the existing session-events WS bus
   (`session_events.rs`), following the `projects_changed` / `agent_status_changed`
   patterns. New event variants are wire-frozen on ship.
3. **Identity comes from the daemon, never client self-report.** A connect-user's
   token already resolves to a username (#617); the owner token resolves to "owner".
4. **Trust-degrading controls are daemon-ENFORCED.** A Viewer that "shouldn't type"
   must be rejected server-side, not just hidden client-side.

---

## 1. Presence — "how many users are viewing this server?"

### What it is
A daemon-owned registry of connected client instances, resolved to display users,
broadcast on change, rendered as a presence chip.

### Daemon
- New module `crates/k2so-daemon/src/presence.rs`:
  - `register(conn_id, actor)` / `unregister(conn_id)` called from the WS accept/
    teardown chokepoints: `session_events_ws.rs` (app-level subscription = "client
    is open") and `sessions_grid_ws.rs` (per-terminal attach = "viewing session X").
  - `actor` resolved at accept time from the request token:
    owner token → `{ user: "owner", role: Owner }`;
    connect-user session → `{ user: <username>, role: <role> }` (#617/#629 stores).
  - Registry shape: `conn_id → { user, role, kind: app|grid, session_id?, connected_at }`.
- New **APP-LEVEL** event (wire-frozen):
  `{ "kind": "presence_changed", "viewers": [{ "user": string, "role": string,
  "connections": number }], "total": number }` — full set, not a diff (same
  convergence rationale as `ActiveChanged`).
- New GET `GET /cli/presence` → same shape (snapshot on connect / reconnect).

### Client (visible surface)
- **Top bar, right side, next to the host indicator**: an eye/person chip — `👁 3` —
  hover reveals the user list ("rosson (owner), baden ×2"). Hidden when the only
  connection is this client (no noise in solo local use).
- **Per-terminal**: small "2 watching" affordance in the terminal pane header, shown
  ONLY when that session's `subscriber_count() > 1`.

### Nuances / decisions
- **D1 — where do per-terminal counts ride?** Recommendation: the app-level
  `presence_changed` event carries only user totals; per-terminal viewer counts ride
  the grid WS (a `viewer_count` field on its existing lifecycle frames). Keeps the
  app-level event small and stable.
- Two windows of one user = 2 connections, 1 displayed user (dedupe by user, "×N").
- Presence is *connection* truth, not "actively looking" truth. No focus/idle
  detection in this release.
- **Room-presence extension** (viewer badges on workspaces + tabs, opt-in
  focus sharing, shared-space emphasis) is parked with the claiming ideas in
  `.k2so/prds/multi-user-presence-and-claiming.md` — revisit after this
  release ships.

---

## 2. Viewer role — attach without claiming the session

### What it is
A role that can SEE everything but cannot type, claim the PTY size, or mutate state.

### Daemon (enforcement — the real feature)
- Extend `k2so_core::connect_users::Role` with `Viewer` (below Member).
- `sessions_grid_ws.rs`: for a Viewer-token connection, **drop input frames and
  `SetActive` claims** (log once per connection). The 0.39.43 arbitration then never
  sees a Viewer, so the PTY keeps the size of the real operator. Read path unchanged.
- Route gating: Viewer passes `token_ok` (reads) but mutating routes reject it.
  Implementation: a `require_writer()` guard (role ≥ Member) applied across the
  mutating route families (audit list: Appendix A).
- Owner panel (Settings → Users, #617) gets "Viewer" in the role dropdown.

### Client (visible surface)
- **Settings → Users** (owner panel): role dropdown gains "Viewer".
- **Top bar**: a logged-in Viewer sees a persistent "👁 viewing" pill; terminal panes
  render input-disabled (composer hidden; first swallowed keystroke shows a one-time
  "view-only" toast).
- **Local mode**: a *session-local* "View only" toggle in the terminal pane header's
  overflow menu — same client behavior with no daemon role (the owner watching their
  own fleet without bumping PTY sizes; the client simply never sends claims/input).

### Nuances
- Viewer ≠ anonymous: presence shows their name.
- A Viewer CAN rearrange their own ephemeral view (per-client view state, 0.39.42),
  but layout-persistence writes are rejected. **D2:** acceptable for this release;
  per-user persisted layouts are a later feature.

---

## 2.5 Size claim & auto-fit — multi-user sessions (added 2026-06-10)

### The reframe that shrinks this problem
With the composer (§4), MOST collaboration never needs the PTY's size or
input box: humans talk to the agent through serialized, identity-stamped
composer sends. Claiming is a CONTROL handoff for the rare "I need the
wheel" moments (menus, /commands, Ctrl+C) — not the default mode of
participation. Build order reflects this: composer first.

### What counts as an interaction (and tracking)
- Raw PTY keystrokes (already claim — 0.39.43 §6.4) and explicit
  `SetActive` claims with dims (already tracked).
- **Composer sends NEVER claim** (deliberate — see reframe).
- Client-local scroll never claims (reading must not steal the size).
- NEW daemon tracking (small): `last_interaction_at` per subscriber;
  username stamped on the claim (the grid WS already resolves its token);
  `size_claim_changed` broadcast; claim cleared on owner disconnect.

### Reclaim rules
- **Last-typist-wins stays**, with **~5s hysteresis**: a challenger's
  keystrokes pass through immediately, but the RESIZE waits until the
  current owner has paused typing for the window. Kills resize ping-pong
  without blocking input. Pure function, unit-tested.
- **Explicit handoff**: pane-header chip "⌗ sized for <user>";
  click-to-claim, displaced owner gets a quiet toast. No request/approve
  ceremony (trusted-team default; polite mode is a later option).
- **Typing hint**: "<user> is typing" indicator when another subscriber's
  input frames are flowing — the social signal that prevents wheel-grabs.

### Auto-fit (view side — the CMD+Shift+± mechanism, automated)
- Non-owners default to **auto-fit**: per-terminal font auto-steps DOWN
  until the owner's full cols×rows grid fits the local pane; letterbox
  centered when the local pane is larger. Whole-app zoom (CMD+±) is
  untouched/personal.
- **Font floor ~9px**: below it, stop shrinking and clip+scroll instead.
- Manual CMD+Shift+± overrides auto-fit for that pane until reset;
  becoming the owner restores native sizing.
- Honest caveat: auto-fit cannot re-WRAP — viewers see the owner's
  reflow faithfully (correct-but-small beats wrong-but-comfortable).
  Same physics as the single PTY size (TIOCSWINSZ is kernel truth; a
  TUI renders for exactly one width — per-viewer re-rendering would
  require tmux-style re-hosting, explicitly out of scope).

### Where it sits
After §1 (presence: names), §2 (Viewer role: a Viewer can never claim),
and §4 (composer: removes most demand). Daemon bits ≈ a day with tests;
auto-fit is a contained renderer feature on the existing font-size
machinery.

---

## 3. Follow mode (bonus) — follow the activity

### Scope decision
**Follow-the-activity only.** Follow-the-user requires sharing per-client focus,
which 0.39.42 deliberately made private per-client; defer that half.

### How it works (client-only; the daemon signal already exists)
- Subscribes to existing `agent_status_changed` app-level events.
- When ON: on `start` (and on `permission`, with priority), auto-select the workspace
  + tab whose pane transitioned, debounced 2s so a chatty fleet doesn't thrash.
  Never sends claims (same suppression as view-only).
- **Visible surface:** "Follow ⤳" toggle in the top bar next to the presence chip,
  plus `Cmd+Shift+F`. Active state shows the workspace it last jumped to.

### Nuances
- Follow + Viewer compose naturally (a follower is implicitly view-only while ON).
- `permission` always outranks `start` (a blocked agent is what you want to see).

---

## 4. Message Composer / Queue — the headline feature

### Problem
Typing into a live PTY races the daemon's `k2so msg` injections — your half-typed
text gets spliced into AI traffic. And human messages carry no identity.

### What it is
A per-user compose area docked beneath the terminal. Drafts live client-side; **send
goes through the daemon's 0.39.45 verified-injection machinery**, so delivery is
atomic, ordered, framed, and confirmed.

### Daemon
- New POST `POST /cli/terminal/send-message` (session-scoped, unlike workspace-scoped
  `/cli/workspace/msg`):
  - body (form or JSON): `session_id`, `text`, optional `queue=true`.
  - `from` resolved **from the request token** (connect-user → username; owner-token
    local → no prefix for now, per Rosson). The client never supplies the name.
  - delivery = `inject_payload_verified()` on that session's PTY: bracketed-paste
    framing + verify-and-resubmit + honest `no_submit`.
  - **Per-session injection serialization:** a per-session async lock so concurrent
    sends (human + `workspace_msg` + another human) deliver strictly one-at-a-time;
    `workspace_msg::inject_live` takes the same lock. This is the actual anti-splice
    guarantee — and it also fixes today's latent risk of two concurrent `k2so msg`
    calls interleaving on one session.
  - `queue=true` (Phase 2 of this feature): if the recipient's input region is
    non-empty or an injection is in flight, hold in a daemon-side FIFO and drain when
    the input region reads empty (the 0.39.45 grid-read primitive makes "free"
    detectable). Queue depth broadcast via a WORKSPACE-SCOPED
    `message_queue_changed` event.
- Response = the canonical `MsgResponse` shape, so clients render verified-delivery
  states truthfully.

### Client (visible surface)
- **Beneath each terminal pane** (sibling of the alacritty view inside the pane
  container, `src/renderer/components/Terminal/`): a collapsible composer strip.
  Collapsed: a thin "✎ Compose…" bar. Expanded: auto-growing textarea (Enter = send,
  Shift+Enter = newline), send button, and a status lane per message:
  *queued → injecting → delivered ✓ / no_submit ⚠*.
- Per-user by construction: drafts are client-local; identity is the daemon-resolved
  login. **Local mode: no name prefix for now.**
- Composer hidden entirely for Viewer role.

### Nuances / decisions
- **D3 — composer never writes raw keystrokes.** Raw typing in the terminal remains
  available for TUI *control* (menus, shortcuts); the composer is for *messages*.
  Stated in the strip's placeholder copy.
- **D4 — prefix format:** reuse `format_message()` → `"[from rosson] …"`, the shape
  agents already parse. Owner-token local sends pass empty `from` and the daemon
  omits the prefix entirely (small new branch in `format_message`).
- **D5 — multi-line is now safe:** bracketed-paste framing means newlines in a
  composed message do NOT submit early — a real improvement over typing in the PTY.

---

## 5. Work-finish notifications — visual

### Signal (already shipped)
`agent_status_changed { paneId, tabId, status: start|stop|permission }` — emitted at
the daemon's hook chokepoint; reaches local and K2 Connect clients identically.

### Behavior
- Notify on:
  - `permission` — **always** (an agent is blocked on you).
  - `stop` — only when the run lasted ≥ N seconds (default 30, configurable) AND
    (window unfocused OR that workspace isn't the active one). A 12-agent fleet
    idling constantly must not spam.
- Two layers:
  - **Native OS notification** (Tauri notification plugin; permission prompt on first
    enable): "scout_v3 finished (2m 14s)" / "cortana needs permission". Click →
    focus app + jump to that workspace/tab.
  - **In-app**: amber (permission) / green (finished) dot on the workspace icon in
    the IconRail + sidebar row — extends the existing spinner-state machinery in
    `stores/active-agents.ts`.

### Visible surface
- **Settings → Notifications (new section)**: master toggle, per-event toggles,
  min-run-length slider, and a per-workspace override row on each workspace's
  Settings page.

### Nuances
- Run duration: the event doesn't carry it; the client computes from its own observed
  `start` timestamp per paneId (good enough, no wire change). If daemon truth is ever
  needed, append a new event variant — never mutate the frozen one.
- A future web client can't always show native notifications — the in-app layer is
  the guaranteed floor.

## 6. Work-finish notifications — audio

- Same triggers as §5, separate toggles. Two distinct short sounds (finish vs
  permission), bundled assets, played renderer-side.
- Defaults: **permission ON, finish OFF** (finish chimes get old fast; permission is
  genuinely interrupt-worthy).
- Shares §5's settings rows. Volume follows system.

---

## 7. Build order & sizing

| # | Feature | Size | Depends on |
|---|---------|------|------------|
| 1 | Presence registry + event + chip | S | — |
| 2 | Viewer role (daemon-enforced) + local view-only toggle | M | 1 (display) |
| 3 | Composer + send-message route + per-session injection lock | M-L | 0.39.45 injection |
| 4 | Visual notifications + Settings section | S-M | — |
| 5 | Audio notifications | S | 4 |
| 6 | Follow-the-activity | S | 1, 2 (suppression) |
| 7 | Composer queue=true (drain-on-idle) + queue events | M | 3 |
| 8 | Size claim & auto-fit (§2.5) | M | 1, 2, 3 |

§7 can slip to 0.40 without weakening §3 — send-now is the core value.

## 8. GH #12 rider — terminal perf

The public commitment on #12 says it lands in "the final 0.39.x". Minimum rider:
**per-row memoization** in `AlacrittyTerminalView.tsx` (extract a `React.memo`'d
`<Row>`, content-keyed). Server-side frame coalescing + the WebGL evaluation stay in
`terminal-smoothness-rnd.md` for 0.40 — UNLESS the 2026-06-10 remote-rendering bug
investigation finds a shared root cause in the delta path, in which case that fix
moves into this release.

---

## Appendix A — Viewer-rejection audit list
Every POST-allowlisted route family in `routes/dispatcher.rs` + the mutating GETs:
`workspace/msg`, `set-agent-display-name`, `set-chat-session`, `mode`, `worktree`,
`agentic`, inbox mutations, layouts save/delete, projects/*, git/*, fs writes,
terminal create/kill/resize/write/send-message, skills/agents writes, heartbeat
mutations, clone bundle/unpack (tunnel/users already owner/admin-gated). Plus the
grid-WS input frames and SetActive claims.
