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
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
- 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) 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.
- 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 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
| # | 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.