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

Contents
  1. Presence — who's viewing the server
  2. Viewer role — attach without claiming
  3. Size claim & auto-fit — multi-user sessions
  4. Follow mode (bonus)
  5. Message Composer / Queue
  6. Work-finish notifications — visual
  7. Work-finish notifications — audio
  8. Build order + the #12 rider

Ground rules for everything below

1Daemon-first. Who's connected, what role they hold, what's queued — all canonical in the daemon. Clients render truth and send gestures.
2Events over polling. Everything rides the existing session-events bus, same pattern as projects_changed / agent_status_changed.
3Identity comes from the daemon. The login token resolves to a username server-side. Clients never self-report who they are.
4Restrictions 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 SMALLDAEMONCLIENT

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 MEDIUMDAEMONCLIENT

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 MEDIUMDAEMONCLIENT

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

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

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)

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) SMALLCLIENT

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-LARGEDAEMONCLIENT

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-MEDCLIENT

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.

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 SMALLCLIENT

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

#FeatureSizeDepends on
1Presence registry + event + chipS
2Viewer role (daemon-enforced) + local view-only toggleM1
3Composer + send-message route + per-session injection lockM-L0.39.45 injection
4Visual notifications + Settings sectionS-M
5Audio notificationsS4
6Follow-the-activityS1, 2
7Composer queue=true (drain-on-idle)M3 · can slip to 0.40
8Size claim & auto-fit (§2.5)M1, 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.