# PRD: Session lifecycle + "Active" correctness

**Status:** Draft 2026-06-05. Confirmed by two Explore deep-dives (renderer + daemon).
**Owner:** Rosson + pod-leader. **Target:** 0.39.34 (also closes creature-comfort #4 / #588's spirit).
**One-liner:** Make "Active" mean *what's actually alive and working* (not *what you're looking at*), make live sessions track the Active set instead of piling up, and let the user tune how long Active sticks around.

---

## 1. Problem

Three observations, one root-cause family — K2's "Active" signal tracks **what you're viewing**, not **what's alive/working**:

1. **RAM:** sessions for workspaces untouched for days keep running (~130 MB each). Observed **10 live sessions for 6 Active-bar workspaces**; **nothing reaps a session when its workspace ages out of the 24 h window.**
2. **Spinner:** the braille "working" indicator persists for a fresh Cmd+T tab but **not** for the pinned-Chat session.
3. **Active semantics:** clicking into a workspace doesn't actually add it to Active; a heartbeat doing autonomous work in a hidden session is invisible.

**Principle (Rosson):** unless the user keeps a workspace Active, there's no reason to hold RAM for its sessions. "Active" = *had activity in the last N hours* — and **N must be user-configurable** (it should stick around "a little while" after activity stops).

## 2. Confirmed root causes (file:line)

1. **Spinner** — `active-agents.ts:213`: `recordTitleActivity` binds `paneId → activeProjectId` on the FIRST `'working'` signal. The pinned-Chat pane has **no lifecycle hook** (only title-activity), so if its first tick fires while you're viewing another workspace, it mis-binds → `getProjectStatus(itsOwnProject)` misses. A fresh Cmd+T tab binds via `handleLifecycleEvent` (hook, `active-agents.ts:233-244`), correct regardless of foreground. **Fix:** register the pane→projectId mapping **upfront at AgentChatPane mount** — `projectId` is a direct prop (`AgentChatPane.tsx:76`) and the terminalId is `agent-chat:<projectId>` (`terminal-id.ts`), so no `activeProjectId` race.
2. **Active triggers** — `projects.ts:485` `setActiveWorkspace()` calls `touchInteraction` ✓, but `setActiveProject()` (`projects.ts:404-447`, the project-icon-rail click) does **not** → opening a project doesn't reset the window. **Fix:** add `touchInteraction(id)` in `setActiveProject` (~445).
3. **Aged-out reap** — there is **no reap** when a workspace leaves the Active window (`ActiveBar.tsx` rule 2). The daemon **cannot** drive it: `watchdog.rs` measures *idle time* (not workspace age) and explicitly **skips v2 sessions**; the daemon has no access to `lastInteractionAt`/`manuallyActive` (renderer-only state). → Age-out reap must be **renderer-driven**, extending #657's dismiss-reap timer to the age-out case. The lingering sessions originate **renderer-side** (the daemon map starts empty; restart-recovery only fires on a renderer spawn request) — likely `backgroundWorkspaces`/`restoreLayout` mounting tabs for non-active workspaces on boot. **So the fix must also gate boot-restore to the Active set, not just reap after.** *(OPEN-1 below.)*
4. **Heartbeat→Active surface** — path exists: `workspace_sessions.surfaced=1` + `HookEvent::SessionSurfaced` (`k2so_core/workspace/session.rs`) → renderer listener; `/cli/session/set-surfaced` (`agents_routes.rs:834-863`). But `heartbeat_launch` does **not** currently call set-surfaced → surfacing is incomplete. No-op vs work: `heartbeats/control.rs` `heartbeat_noop/action` (`consecutive_no_ops`), but the daemon-side call path is unclear. *(OPEN-2 below.)*

## 3. Solution (5 parts)

- **A. Spinner fix** — `AgentChatPane` registers its `paneId → projectId` in the active-agents store at mount (before any working-signal); make `recordTitleActivity` prefer an existing mapping / extract from an `agent-chat:` key rather than defaulting to `activeProjectId`.
- **B. Active triggers** — `touchInteraction` on `setActiveProject` (and confirm `setActiveWorkspace`), so **opening a workspace joins Active + resets the window**.
- **C. Configurable Active window (NEW)** — replace the hard-coded `TWENTY_FOUR_HOURS` (`ActiveBar.tsx`) with a user setting **`activeWindowHours` (default 24)**, stored in `app_settings` (k2so-core JSON app_settings), surfaced in **General settings**. This single value drives **both** the Active-bar rule-2 window **and** the age-out reap timing.
- **D. Age-out reap (renderer-driven, extends #657)** — when a workspace's `lastInteractionAt` exceeds `activeWindowHours` AND it's not `manuallyActive` AND not heartbeat-working, schedule the same `v2/close` reap (reuse #657's 15 s grace + cancel-on-return). **Also gate boot-restore** so non-active workspaces don't spawn sessions in the first place. Lazy-resume on return via the existing `--resume` path.
- **E. Heartbeat→Active** — on a heartbeat wake that **does work** (not a no-op), call `set-surfaced(true)` + a renderer `touchInteraction` so the workspace appears in Active, with a **distinct "autonomous" indicator** (so the user can tell self-driving from user-driving). Skip no-op wakes.

## 4. The setting (General)

> **"Keep workspaces Active for `[ 24 ]` hours after activity."**
> Lower = more aggressive reaping (less RAM). Higher = sessions stick around longer. Min 1 h. Default 24.

Read by: `ActiveBar` (rule 2) + the age-out reaper. Persisted in app_settings; host-aware if/where relevant.

## 5. Open implementation questions (resolve in Phase-1 step 1 — NOT more research)

1. **OPEN-1:** the exact renderer boot-restore behavior (`restoreLayout` / `backgroundWorkspaces`) — does it spawn non-active workspaces' sessions on app start? If so, gate it to the Active set. (This is the true source of the 10-vs-6.)
2. **OPEN-2:** where `heartbeat_noop` / `heartbeat_action` are invoked (hook callback? scheduler? renderer Tauri cmd?) — the gate for "surface on work, not on no-op." If no clean daemon-side signal exists, v1 may surface on *any* work-producing wake and refine later.

## 6. Phasing

- **P1 (small, high-value, low-risk):** A (spinner) + B (active triggers) + C (configurable window setting). No lifecycle risk.
- **P2 (the RAM win, more care):** D (age-out reap + boot-restore gate). Touches session lifecycle — land with tests + a real soak.
- **P3 (depends on OPEN-2):** E (heartbeat→Active + autonomous indicator).

## 7. Tests

- Spinner: pinned-Chat working-signal fires *while viewing another workspace* → its Active item still shows the spinner (regression on the binding).
- Active window: changing `activeWindowHours` changes when a workspace leaves Active **and** when its session is reaped.
- Age-out reap: past-window + not pinned → reaped after grace; re-open within grace → cancelled; session resumes on return; non-active workspace doesn't spawn a session on boot.
- Heartbeat: work-producing wake → surfaces + autonomous indicator; no-op wake → does not surface.

## 8. Related

#656 (reaper), #657 (dismiss-reap), #658 (cold-boot spawn), #588/creature-comfort #4, #604/#606/#608. Memory: [[project_0.39.33_remote_update_system]] (the prior lifecycle work this builds on).
