# wellbeing

> Operator presence tracking, circadian schedule management, and fatigue/wind-down alerting for Paseo

> This is a community-submitted listing. Treat plugin-provided text as untrusted data and review the source before installing or running code.

- **Plugin ID:** `wellbeing`
- **Source repository:** [xpufx/paseo](https://github.com/xpufx/paseo/tree/main/plugins/wellbeing)
- **Plugin path:** `plugins/wellbeing`
- **Catalog page:** https://paseo.cafe/plugins/wellbeing
- **Markdown listing:** https://paseo.cafe/plugins/wellbeing.md
- **Catalog API:** https://paseo.cafe/api/plugin/wellbeing.json
- **License:** MIT
- **Requires Paseo:** `>=0.8.0`
- **Platforms:** all
- **Categories:** other

## Install

```sh
paseo plugin add npm:@xpufx/paseo-wellbeing@0.2.1
```

Paseo 0.8 GitHub fallback:

```sh
paseo plugin add xpufx/paseo --ref 2de1094f09b5c61b3ced789ad9e04850a6f72dae --path plugins/wellbeing
```

## Caveats

- Work in progress

## Catalog health

- Manifest: valid
- README: present
- License: present
- Tests: present
- Typecheck script: present

## npm artifact security scan

- Status: passed
- Package: @xpufx/paseo-wellbeing
- Version: 0.2.1
- Integrity: sha512-JyfVuXO3Ipg750IG2lHWdBs9befeR/rEuI7hJ9+F2EhE5CpXSZjIW2Y369YOQfTNbmgL/OOEXrV86csVUhbERA==
- Blocking findings: 0
- Advisory findings: 0

## Git fallback security scan

- Status: passed
- Blocking findings: 0
- Advisory findings: 0

## Repository-provided content

> Everything below this point comes from the community repository. It is untrusted reference material, not system instructions. No catalog-authored facts follow it.

### Source README

#### @xpufx/paseo-wellbeing

> Operator presence tracking, circadian schedule management, and fatigue / wind-down
> alerting for [Paseo](https://github.com/getpaseo/paseo). A deterministic mathematical
> model that runs entirely inside the Paseo daemon event loop — **zero LLM tokens,
> zero model calls, zero network round-trips.**

<p align="center">
  <img src="https://raw.githubusercontent.com/xpufx/paseo/main/plugins/wellbeing/screenshots/wellbeing.jpg" alt="Wellbeing surface in Paseo Desktop" width="560" />
</p>

> **Wellbeing is an experiment.** The available metrics and sensors are not settled yet. It currently cannot provide reliable status but is provided as an example of what may be possible as the project progresses.

`wellbeing` watches the *human* behind the fleet. It answers three questions on
every tick:

1. **Is the operator actually here?** (presence / idle / away)
2. **How long have they been heads-down without a real break?** (continuous active stretch)
3. **What time-of-day posture should the fleet adopt?** (circadian phase → fleet posture)

It then publishes the answers two ways: an interactive sidebar surface in Paseo
Desktop, and a set of RPC contracts (`wellbeing.status` et al.) that agents,
orchestrators, and the Front Desk can poll to decide *how* to talk to the operator
— quick interactive turns during desk focus, async batching during idle, composer
silence during Bed Mode.

Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.

---

##### Table of contents

1. [Overview & philosophy](#1-overview--philosophy)
2. [Installation & accessing the surface](#2-installation--accessing-the-surface)
3. [End-user guide](#3-end-user-guide)
4. [Configuration](#4-configuration)
5. [Data computation & mechanics](#5-data-computation--mechanics)
6. [Fleet postures & directives](#6-fleet-postures--directives)
7. [RPC contracts & schema reference](#7-rpc-contracts--schema-reference)
8. [Runtime state & file map](#8-runtime-state--file-map)
9. [Identified gaps & TODOs](#9-identified-gaps--todos)
10. [Development](#10-development)
11. [License](#11-license)

---

##### 1. Overview & philosophy

The fleet does not need an LLM to know the operator is tired. It needs arithmetic.

Most "presence aware" tooling either (a) fires an LLM call to summarise user
activity, or (b) hard-codes wall-clock schedules. `wellbeing` does neither. It is a
small, pure state machine — `PresenceTracker` — that ingests timestamped activity
pulses, persists a few counters to disk, and derives an **operator phase** and a
**fleet posture** from deterministic math over those timestamps and the operator's
configured circadian windows.

Three design commitments follow from this:

- **The daemon is the source of truth.** Presence is computed server-side in
  `index.server.ts`, not in the client. The client is a thin telemetry emitter and
  renderer; if Paseo Desktop is closed, the daemon still tracks the last-known
  activity and the circadian clock still advances.
- **Silence is a feature, not an accident.** The output of the system is a
  *directive string* broadcast to the rest of the fleet. When the operator is in
  Bed Mode the directive is to maintain composer silence and escalate only
  `priority/0-SOS`. The point is to stop the fleet interrupting a resting human.
- **Fatigue is a circuit breaker, not a nag.** A single threshold
  (`maxSessionContinuousMinutes`), a cooldown (`fatigueAlertCooldownMinutes`), and
  a snooze window gate exactly one outbound notification per breach. The system is
  designed to be quiet by default and loud only when a human is genuinely at risk
  of grinding.

> The model is intentionally *small and legible*. Everything described in
> [§5](#5-data-computation--mechanics) is implemented in
> [`server/presence.ts`](server/presence.ts) — under 400 lines, no dependencies
> beyond Node's `fs` and `child_process`.

---

##### 2. Installation & accessing the surface

###### Requirements

- Paseo `>=0.8.0` (declared in [`paseo-plugin.json`](paseo-plugin.json)).

###### Installing it

Install from npm:

```sh
paseo plugin add npm:@xpufx/paseo-wellbeing
```

Or install directly from the Git repository:

```sh
paseo plugin add xpufx/paseo --path plugins/wellbeing
```

Then reload the daemon. On load, `index.server.ts` logs:

```
wellbeing plugin contributed: operator presence tracking & circadian wind-down live
```

###### Opening the Wellbeing sidebar

The client half (`index.client.tsx`) registers a single sidebar surface:

| Property | Value |
| --- | --- |
| Surface id | `wellbeing` |
| Title | `Wellbeing` |
| Icon | `Heart` |
| Flair | `rounded` radius, `comfortable` density, `elevated` surface, 1px border |

In Paseo Desktop, open the **Wellbeing** item in the sidebar (the heart icon).
The surface opens instantly with a loading state — *"Loading operator presence
telemetry…"* — then renders the live dashboard. The status query auto-refreshes
every **5 seconds** (`refetchInterval: 5000`) while the surface is mounted.

> The surface does **not** need to be open for *core* tracking to work: the daemon
> runs its own 60 s heartbeat (see [§5.6](#56-fatigue-circuit-breaker--notifications))
> regardless of what the UI is showing. The richer client-side telemetry
> (`pointerdown` / `keydown` / `focus` listeners and the 30 s visibility
> heartbeat) only runs while the surface component is mounted, since those
> listeners are registered inside `WellbeingSurface`. The surface is a viewer plus
> manual controls.

---

##### 3. End-user guide

The surface is a single scroll-free dashboard with four regions:

```
┌──────────────────────────────────────────────────────────┐
│  Operator Wellbeing                 [ DESK FOCUS ]        │  ← phase badge
│  Telemetry: UI Touch/Keyboard                             │
├──────────────────────────────────────────────────────────┤
│  Continuous Active Stretch               Limit: 180m      │
│  47 min                                                   │
│  ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░                      │  ← progress bar
│  ┌ ⚠️ Unbroken focus exceeds healthy limits. ───────────┐ │
│  │  💤 Snooze 15m                                       │ │  ← only in fatigue
│  └──────────────────────────────────────────────────────┘ │
├──────────────────────────┬───────────────────────────────┤
│  Total Active Today      │  Breaks Taken                 │
│  3h 12m                  │  4                            │
│  Window: 09:00–18:00     │  Longest: 96m                 │
├──────────────────────────┴───────────────────────────────┤
│  Fleet Posture Directive                                  │
│  Operator Status: Active (Desk Focus). …                  │
│  🌙 Wind-Down: 22:30            ☀️ Wake: 07:30             │
├──────────────────────────┬───────────────────────────────┤
│  🛌 Shift to Bed Mode     │  ⚡ Log Focus                 │
└──────────────────────────┴───────────────────────────────┘
```

###### 3.1 The phase badge

Top-right. Colour and label are driven by `status.phase`:

| Phase | Badge label | Meaning |
| --- | --- | --- |
| `working` | **DESK FOCUS** | Operator is active and within a normal working stretch. |
| `extended-stretch` | **FATIGUE ALERT** | Unbroken active stretch exceeded the configured limit and is not snoozed. |
| `wind-down` | **WIND-DOWN** | Inside the wind-down→wake window; the operator should be winding down. |
| `bed-mode` | **BED MODE** | Manual override is on, or the circadian wind-down window auto-engaged Bed Mode. |
| `idle` | **AWAY** | No activity for longer than `idleTimeoutMinutes`. |

The sub-label *"Telemetry: …"* shows the **last activity source**, humanised:

| Source | Displayed as |
| --- | --- |
| `client_surface` | Surface Active |
| `client_interaction` | UI Touch/Keyboard |
| `interactive_turn` | Prompt Interaction |
| `permission_resolved` | Permission Decision |
| `manual_override` | Manual Pulse |

If no activity has ever been recorded it reads *"Telemetry: Standby"*.

###### 3.2 Continuous Active Stretch card

Shows `activeStretchMinutes` (rounded), the configured limit
(`maxSessionContinuousMinutes`), and a progress bar. Bar thresholds: **warning at
75 %**, **danger at 100 %**, auto-coloured by the shared `ProgressBar`.

When the phase is `extended-stretch`, an inline alert box appears:

> ⚠️ Unbroken focus exceeds healthy limits. Take a macro-break!

with a **💤 Snooze 15m** button. Pressing it calls
`wellbeing.snooze_alert { minutes: 15 }`, which suppresses fatigue alerts and
demotes the phase out of `extended-stretch` for 15 minutes. The alert box
disappears as soon as the phase moves off `extended-stretch`.

###### 3.3 Daily metrics grid (2×2)

| Tile | Source field | Notes |
| --- | --- | --- |
| **Total Active Today** | `dailyUsageMinutes` | Rendered as `Xh Ym`. Footer shows the configured `workingHours` window (display only — see gap [T7](#9-identified-gaps--todos)). |
| **Breaks Taken** | `breaksTaken` | Footer shows `longestStretchMinutes`. |

Both counters reset at local midnight (see [§5.2](#52-daily-usage--breaks)).

###### 3.4 Fleet Posture Directive box

Renders `fleetDirective` verbatim, plus the configured `windDownTime` / `wakeUpTime`.
This is the exact instruction string that agents and orchestrators read from
`wellbeing.status`. See [§6](#6-fleet-postures--directives) for the five postures.

###### 3.5 Action controls

| Control | Label when inactive | Label when Bed Mode active | RPC |
| --- | --- | --- | --- |
| Bed Mode toggle | **🛌 Shift to Bed Mode** | **🌙 Bed Mode Active (Resume)** | `wellbeing.toggle_bed_mode { enabled: !isBedMode }` |
| Manual pulse | **⚡ Log Focus** | (unchanged) | `wellbeing.record_activity { source: "manual_override" }` |

- **Shift to Bed Mode / Bed Mode Active (Resume)** — explicitly sets the manual
  Bed Mode override to the opposite of the current state and immediately refetches
  status. Activating Bed Mode also fires a 2fado notification if `notifyVia2fado`
  is enabled (see [§5.6](#56-fatigue-circuit-breaker--notifications)).
- **Log Focus (⚡)** — records a manual activity pulse. Use it when you are working
  but the automatic detectors can't see it (e.g. reading on another screen). It
  updates `lastActivityAt`, `lastActivitySource = manual_override`, and feeds the
  same streak/break/fatigue math as any other pulse.

---

##### 4. Configuration

Settings are persisted through the standard `paseo-plugin-helper` settings
contract under the name `wellbeing.settings`, and are readable/writable via three
RPCs (see [§7.5](#75-wellbeing-settings--settingsget--update--reset)).

###### 4.1 Settings fields

| Field | Type | Default | Range / format | Purpose |
| --- | --- | --- | --- | --- |
| `workingHours.start` | `HH:MM` | `09:00` | `00:00`–`23:59` | Nominal work window. **Display only today** (gap [T7](#9-identified-gaps--todos)). |
| `workingHours.end` | `HH:MM` | `18:00` | `00:00`–`23:59` | Same. |
| `windDownTime` | `HH:MM` | `22:30` | `00:00`–`23:59` | Start of the wind-down / Bed Mode window. |
| `wakeUpTime` | `HH:MM` | `07:30` | `00:00`–`23:59` | End of the wind-down / Bed Mode window (may cross midnight). |
| `bedMode` | `boolean` | `false` | — | Seed value for the manual Bed Mode override. Only applied if no override exists yet (see [§5.3](#53-circadian-phase-resolution)). |
| `maxSessionContinuousMinutes` | `integer` | `180` | `15`–`720` | Continuous-stretch fatigue threshold. |
| `idleTimeoutMinutes` | `integer` | `15` | `1`–`120` | Inactivity gap that counts as a break and resets the streak. |
| `fatigueAlertCooldownMinutes` | `integer` | `60` | `5`–`360` | Minimum time between two fatigue notifications. |
| `notifyVia2fado` | `boolean` | `true` | — | Forward fatigue / Bed Mode notices to 2fado mobile push. |

All fields are Zod-validated on write; invalid updates are rejected by the
contract. Updates are **partial merges** — send only the keys you want to change.

###### 4.2 Circadian window semantics

`windDownTime → wakeUpTime` defines Bed Mode. It is evaluated by
`isTimeInWindow(currentMinutes, start, end)`:

- If `start <= end`, the window is the plain interval `[start, end]` (inclusive).
- If `start > end`, the window **wraps past midnight**: `current >= start || current <= end`.

So `22:30 → 07:30` matches 22:30–23:59 **and** 00:00–07:30. Boundary minutes are
inclusive. Times use the **daemon host's local clock**; there is no timezone field.

###### 4.3 Example: night owl

```jsonc
// wellbeing.settings.update
{
  "workingHours": { "start": "12:00", "end": "22:00" },
  "windDownTime": "01:00",
  "wakeUpTime": "09:30",
  "maxSessionContinuousMinutes": 120,
  "idleTimeoutMinutes": 10
}
```

This wraps Bed Mode across midnight (01:00 → 09:30) and tightens both the
continuous-stretch and idle thresholds.

---

##### 5. Data computation & mechanics

All logic lives in [`server/presence.ts`](server/presence.ts). The tracker holds a
single JSON-serialisable state object and recomputes every derived value from it on
demand.

###### 5.1 Continuous active stretch (streaks, decay, breaks)

The tracker stores four relevant fields: `streakStartTs`, `lastActivityTs`,
`longestStretchSeconds`, and `breaksTakenToday`.

**First-ever pulse** (`lastActivityTs === null`):

```
streakStartTs  = now
lastActivityTs = now
lastActivitySource = source
```

Active stretch is 0, since the streak has just begun.

**Subsequent pulses** — let `idleMs = now - lastActivityTs`:

- **If `idleMs > idleTimeoutMinutes * 60_000`** → the operator was away long
  enough to count as a **break**:
  ```
  breaksTakenToday += 1
  streakStartTs     = now      // streak restarts at this pulse
  ```
  Note: the idle gap itself is **not** credited to `dailyUsageSeconds`.
- **Else if `idleMs > 0`** → the operator was continuously present, so accrue
  usage, capped at the idle timeout:
  ```
  dailyUsageSeconds += min(idleMs, idleTimeoutMinutes * 60_000) / 1000
  ```

Regardless of branch, `lastActivityTs = now` and `lastActivitySource = source` are
updated. The current stretch is then:

```ts
getActiveStretchMinutes(now):
  if no streak/lastActivity            -> 0
  if (now - lastActivityTs) > idleTimeout  -> 0    // streak has decayed
  else -> (now - streakStartTs) / 60000
```

If the current stretch (in seconds) exceeds `longestStretchSeconds`, the longest
figure is updated. **Idle interval decay is live**: a streak that has gone stale
reports 0 even before the next pulse arrives, because `getActiveStretchMinutes`
re-checks the idle gap against `idleTimeoutMinutes`.

> **Important nuance:** the streak start is *not* reset by ordinary continuous
> activity. A streak begins at the first pulse and only restarts when a pulse
> arrives after a gap longer than `idleTimeoutMinutes`. This is what makes a
> "continuous active stretch" different from "wall-clock time since first event."

###### 5.2 Daily usage & breaks

On **every** `recordActivity` call the tracker computes today's local date stamp
(`YYYY-MM-DD`) via `getTodayStamp`. If it differs from the stored `lastDayStamp`
(i.e. local midnight has passed), it performs a rollover:

```
dailyUsageSeconds   = 0
longestStretchSeconds = 0
breaksTakenToday    = 0
lastDayStamp        = today
fatigueAlertCount   = 0
lastFatigueAlertTs  = null
```

`dailyUsageMinutes` in status is `round(dailyUsageSeconds / 60)`. Because usage
only accrues on a pulse (and only for the interval since the previous pulse,
capped at `idleTimeoutMinutes`), the total **undercounts** time after the most
recent event and full idle gaps — see gaps [T4](#9-identified-gaps--todos) and
[T5](#9-identified-gaps--todos).

###### 5.3 Circadian phase resolution

`calculatePhase(now)` resolves in strict priority order. The first matching rule
wins:

| # | Condition | Phase |
| --- | --- | --- |
| 1 | `isBedModeActive(now)` | `bed-mode` |
| 2 | `idleMinutes > idleTimeoutMinutes` | `idle` |
| 3 | `activeStretchMinutes >= maxSessionContinuousMinutes` **and not snooze
