# Final 0.39.X Creature Comforts

Design review — presence, viewer role, follow mode, message composer, finish notifications.
DRAFT 2026-06-10 · PRD: .k2so/prds/0.39.x-final-creature-comforts.md · builds on 0.39.45 verified injection

## Ground rules for everything below

1 Daemon-first. Who's connected, what role they hold, what's queued — all canonical in the daemon. Clients render truth and send gestures.
2 Events over polling. Everything rides the existing session-events bus, same pattern as `projects_changed` / `agent_status_changed`.
3 Identity comes from the daemon. The login token resolves to a username server-side. Clients never self-report who they are.
4 Restrictions are daemon-enforced. A Viewer who "shouldn't type" gets their input rejected by the server, not just hidden by the UI.

## 1Presence — who's viewing the server SMALL DAEMON CLIENT

The daemon keeps a registry of connected client instances, resolves each to a display user (connect-user name, or "owner" for the local token), and broadcasts a `presence_changed` event whenever it changes. The plumbing mostly exists — per-terminal attach counts already power the session reaper.

WHERE IT LIVES
① Top bar, right side, next to the host indicator: an eye chip — `👁 3` — hover shows "rosson (owner) · baden ×2". Hidden when you're the only connection (no solo-mode noise).
② Terminal pane header: a small "2 watching" label, shown only when more than one client is attached to that terminal.
┌──────────────────────────────────────────────────────────────┐ │ K2SO nsi-plan01 ▾ 👁 3 ⚡ k2.dev ● │ ← top bar └──────────────────────────────────────────────────────────────┘ hover ⤵ ┌─ Viewing this server ─────────┐ │ ● rosson (owner) │ │ ● baden (member) ×2 │ └───────────────────────────────┘
NUANCE Presence is connection truth, not "actively looking" truth — no focus/idle detection this release. Two windows from one person show as one user with "×2".
DECISION D1 — where do per-terminal counts ride? Recommendation: the app-level event carries user totals only; per-terminal counts ride the per-terminal grid socket each pane already holds. Keeps the app-level event small and wire-stable.

## 2Viewer role — attach without claiming MEDIUM DAEMON CLIENT

Since 0.39.43, "whoever interacts" claims the PTY and drives its size. A Viewer is a login that can see everything but never claims: the daemon drops their input and their size-claims at the socket, and rejects every mutating route for their token. The existing role system (Owner / Admin / Member) gains a fourth, lowest tier.

WHERE IT LIVES
① Settings → Users (owner panel): the role dropdown gains Viewer.
② Top bar (when logged in as a Viewer): a persistent "👁 viewing" pill replaces the user chip.
③ Terminal panes (as Viewer): input disabled; first swallowed keystroke shows a one-time "view-only" toast; the composer (§4) is hidden.
④ Local mode: a "View only" toggle in the terminal pane's overflow (⋯) menu — same behavior without any login, for watching your own fleet without bumping PTY sizes.
NUANCE — enforcement is server-side on purpose The UI hiding input is courtesy; the guarantee is the daemon rejecting Viewer input frames, size claims, and writes. A stale or misbehaving client can't accidentally take over a session or resize the operator's terminal.
DECISION D2 — Viewers and layouts A Viewer can rearrange their own ephemeral view (view state is per-client since 0.39.42), but layout persistence writes are rejected — their arrangement resets on reload. Per-user saved layouts are a separate future feature; this release doesn't attempt it.

## 2.5Size claim & auto-fit — multi-user sessions MEDIUM DAEMON CLIENT

The reframe first: once the composer (§4) exists, most collaboration never touches the PTY's size or input box — humans talk to the agent through serialized, identity-stamped composer sends. Claiming becomes a control handoff for the rare "I need the wheel" moments (menus, /commands, Ctrl+C), not the default mode of participation. That's why the composer builds first: it shrinks this problem before we solve it.

### What counts as an interaction

- Raw keystrokes into the terminal — already claim (since 0.39.43).
- Explicit focus claims with viewport dims — already tracked.
- Composer sends never claim — deliberate.
- Scrolling never claims — reading must not steal the size from whoever's typing.

New daemon tracking (small): a per-viewer `last_interaction_at` timestamp, the username stamped on the claim, a `size_claim_changed` broadcast, and claim-clear when the owner disconnects (no ghost owners).

### Reclaim rules

- Last-typist-wins stays, with ~5s hysteresis: a challenger's keystrokes go through immediately, but the resize waits until the current owner pauses typing. Kills resize ping-pong without ever blocking input.
- Explicit handoff: click the ownership chip to claim; the displaced owner gets a quiet toast. No request/approve ceremony — trusted-team default.
- "<user> is typing" hint when another viewer's keystrokes are flowing — the social signal that prevents two people grabbing the wheel.

WHERE IT LIVES
① Terminal pane header: an ownership chip — `⌗ sized for rosson` — click to claim.
② Terminal pane header (transient): "baden is typing…" hint.
③ Auto-fit indicator in the pane's overflow (⋯) menu: auto-fit on/off + current scale.
┌─ claude · nsi-plan01 ───────── ⌗ sized for rosson · baden is typing… ─┐ │ (viewer at 90% auto-fit — full 190×52 grid visible, letterboxed) │ └────────────────────────────────────────────────────────────────────┘

### Auto-fit (the CMD+Shift+± mechanism, automated)

- Non-owners default to auto-fit: the per-terminal font steps down until the owner's full grid fits the local pane; letterboxed centered when the pane is larger. The whole-app zoom (CMD+±) stays personal and untouched.
- Font floor ~9px — below it, stop shrinking and clip+scroll instead (a laptop watching an ultrawide shouldn't render 4px glyphs).
- Manual CMD+Shift+± overrides auto-fit for that pane until reset; becoming the owner restores native sizing.

NUANCE — auto-fit cannot re-wrap A TUI renders for exactly ONE width (the kernel's TIOCSWINSZ truth) — viewers see the owner's reflow faithfully, just scaled. Correct-but-small beats wrong-but-comfortable. Per-viewer re-rendering would require tmux-style re-hosting of the app — explicitly out of scope.
DECISION D7 — no request/approve handoff ceremony Click-to-claim is instant (with the hysteresis guard). A polite "request control" flow is a later, hosted-tier option if customers want it — a two-person trusted team doesn't.

## 3Follow mode (bonus) SMALL CLIENT

Scoped to follow-the-activity: when ON, your window auto-jumps to the workspace/tab whose agent just started working — and a `permission` event (agent blocked on a human) always outranks a start. Debounced 2s so a chatty fleet doesn't thrash your view. It never claims the session — following implies view-only while active.

WHERE IT LIVES
Top bar, next to the presence chip: a "Follow ⤳" toggle (also `Cmd+Shift+F`). While active it shows the workspace it last jumped to.
NUANCE — why not follow a person? Following another user's focus requires sharing per-client selected-tab state, which 0.39.42 deliberately made private per-client (that's what stopped the tab-sync flicker loops). An opt-in "share my focus" presence field can revisit this later; it is explicitly out of scope now.

## 4Message Composer / Queue MEDIUM-LARGE DAEMON CLIENT

The headline. Today, typing into a live terminal races the server's own message injections — your half-typed message gets spliced by AI traffic. The composer gives every user a private drafting area beneath the terminal; send hands the whole message to the daemon, which delivers it through the same verified-injection machinery 0.39.45 built: paste-framed (can't splice), serialized per session (strictly one injection at a time), and submit-verified (the UI can truthfully say "delivered ✓").

WHERE IT LIVES
① Beneath each terminal pane: a collapsed "✎ Compose…" strip; expands to an auto-growing textarea (Enter = send, Shift+Enter = newline) with a per-message status lane.
② No new top-level UI — it's part of the terminal pane. Hidden for Viewer role.
③ Daemon: new session-scoped `terminal/send-message` route (the workspace-scoped `k2so msg` path is unchanged and gains the same per-session serialization).
│ ╭─ claude ────────────────────────────────────────────╮ │ │ > [from scout_v3] PR #41 is ready for review │ │ │ ● Looking at it now… │ │ ╰─────────────────────────────────────────────────────╯ ├────────────────────────────────────────────────────────── │ ✎ Compose ──────────────────────────────────────────── │ Hold the merge until the migration test passes. Also │ check the rollback path before you approve.▌ │ Shift+Enter = newline [Send ⏎] │ ✓ delivered "double-check the API key handling…" 14:02
NUANCE — identity comes from the login, not the client The daemon stamps `[from rosson]` by resolving the request token server-side. The client never sends a name, so it can't be spoofed and it works identically over K2 Connect. Local mode: no name prefix for now (owner-token sends deliver bare) — exactly as requested; we revisit local naming later if/when local multi-user matters.
NUANCE — the serialization is the real anti-splice fix A per-session injection lock means a human send, a `k2so msg` from another agent, and a second human all deliver strictly one-at-a-time. Today even two concurrent `k2so msg` calls to one session could theoretically interleave — this closes that too.
DECISION D3 — composer ≠ terminal input The composer never writes raw keystrokes. Raw typing in the terminal stays available for TUI control (menus, shortcuts, Ctrl-C); the composer is for messages. Both coexist — the placeholder copy states this.
DECISION D4 — multi-line messages become safe Because delivery is paste-framed, newlines inside a composed message no longer submit early. Multi-paragraph instructions land as one message — something typing into the PTY could never do.
DECISION D5 — "Queue" semantics ship in two phases Phase 1 (this release): send-now through the serialized injector, with truthful per-message status. Phase 2 (can slip to 0.40): `queue=true` — the daemon holds messages while the recipient's input box is busy and drains when it reads empty (the 0.39.45 grid-read primitive makes "free" detectable), with queue depth broadcast to all clients.

## 5Work-finish notifications — visual SMALL-MED CLIENT

The daemon already broadcasts every agent's start / stop / permission transitions (it's what drives the spinners). Notifications are a new consumer of that same signal — no new daemon work.

- permission → notify always. An agent blocked on you is interrupt-worthy.
- finish (stop) → notify only when the run lasted ≥ 30s (configurable) and you weren't already looking (window unfocused, or other workspace active). A 12-agent fleet idling must not spam.

WHERE IT LIVES
① Native macOS notification (with click-to-jump to the workspace/tab) — permission requested on first enable.
② In-app: amber (permission) / green (finished) dot on the workspace icon in the icon rail + sidebar row, extending the existing spinner machinery.
③ Settings → Notifications (new section): master toggle, per-event toggles, min-run-length slider.
④ Per-workspace override row on each workspace's settings page.
NUANCE — "finished" needs a definition `stop` fires on every idle. The ≥30s-run + not-already-looking gate is what makes this a comfort rather than a nuisance. Run duration is computed client-side from the observed `start` timestamp — no wire change needed.

## 6Work-finish notifications — audio SMALL CLIENT

Same triggers as §5, separate toggles, two distinct short sounds (permission vs finish). Bundled assets, played renderer-side.

WHERE IT LIVES
Settings → Notifications, beneath the visual toggles. Shares the per-workspace override row.
DECISION D6 — defaults Recommendation: permission sound ON, finish sound OFF by default. Finish chimes get old fast; a blocked agent is the thing genuinely worth a sound. Push back if you want finish ON.

## 7Build order + the #12 rider

# | Feature | Size | Depends on
--- | --- | --- | ---
1 | Presence registry + event + chip | S | —
2 | Viewer role (daemon-enforced) + local view-only toggle | M | 1
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
7 | Composer queue=true (drain-on-idle) | M | 3 · can slip to 0.40
8 | Size claim & auto-fit (§2.5) | M | 1, 2, 3

THE #12 RIDER The public reply on GH #12 promised terminal perf "in the final 0.39.x". Minimum rider here: per-row memoization in the terminal renderer (high-leverage, low-risk). The remote-rendering bug under investigation (missing line on wrap over K2 Connect) may share a root cause with the delta pipeline — if so, that fix folds into this release too.