{"id":"wellbeing","repo":"xpufx/paseo","url":"https://github.com/xpufx/paseo/tree/main/plugins/wellbeing","package":"@xpufx/paseo-wellbeing","npm":{"package":"@xpufx/paseo-wellbeing","version":"0.2.1","integrity":"sha512-JyfVuXO3Ipg750IG2lHWdBs9befeR/rEuI7hJ9+F2EhE5CpXSZjIW2Y369YOQfTNbmgL/OOEXrV86csVUhbERA==","publishedAt":"2026-09-24T22:33:20.588Z","downloadsLast30Days":0},"name":"wellbeing","description":"Operator presence tracking, circadian schedule management, and fatigue/wind-down alerting for Paseo","categories":["other"],"platforms":[],"caveats":["Work in progress"],"images":["https://raw.githubusercontent.com/xpufx/paseo/main/plugins/wellbeing/screenshots/wellbeing.jpg"],"themes":[],"health":{"manifestValid":true,"hasReadme":true,"hasLicense":true,"hasTests":true,"hasTypecheckScript":true,"updatedRecently":true},"scannedAt":"2026-09-25T20:21:19.637Z","addedAt":"2026-09-25T20:09:34Z","npmSecurity":{"status":"passed","blockingFindings":0,"advisoryFindings":0,"scannedAt":"2026-09-25T20:20:43.268Z","version":"0.2.1","integrity":"sha512-JyfVuXO3Ipg750IG2lHWdBs9befeR/rEuI7hJ9+F2EhE5CpXSZjIW2Y369YOQfTNbmgL/OOEXrV86csVUhbERA=="},"path":"plugins/wellbeing","version":"0.2.1","security":{"status":"passed","blockingFindings":0,"advisoryFindings":0,"scannedAt":"2026-09-25T20:20:43.268Z","commit":"2de1094f09b5c61b3ced789ad9e04850a6f72dae"},"license":"MIT","paseoVersionRequirement":">=0.8.0","descriptionNodes":[{"type":"text","text":"Operator presence tracking, circadian schedule management, and fatigue/wind-down alerting for Paseo"}],"caveatNodes":[[{"type":"text","text":"Work in progress"}]],"manifest":{"id":"wellbeing","requirements":{"paseo":">=0.8.0"}},"repoMeta":{"stars":2,"defaultBranch":"main","pushedAt":"2026-09-25T15:20:13Z"},"owner":{"login":"xpufx","avatarUrl":"https://avatars.githubusercontent.com/u/25581961?v=4"},"readmeText":"# @xpufx/paseo-wellbeing\n\n> Operator presence tracking, circadian schedule management, and fatigue / wind-down\n> alerting for [Paseo](https://github.com/getpaseo/paseo). A deterministic mathematical\n> model that runs entirely inside the Paseo daemon event loop — **zero LLM tokens,\n> zero model calls, zero network round-trips.**\n\n<p align=\"center\">\n  <img src=\"https://raw.githubusercontent.com/xpufx/paseo/main/plugins/wellbeing/screenshots/wellbeing.jpg\" alt=\"Wellbeing surface in Paseo Desktop\" width=\"560\" />\n</p>\n\n> **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.\n\n`wellbeing` watches the *human* behind the fleet. It answers three questions on\nevery tick:\n\n1. **Is the operator actually here?** (presence / idle / away)\n2. **How long have they been heads-down without a real break?** (continuous active stretch)\n3. **What time-of-day posture should the fleet adopt?** (circadian phase → fleet posture)\n\nIt then publishes the answers two ways: an interactive sidebar surface in Paseo\nDesktop, and a set of RPC contracts (`wellbeing.status` et al.) that agents,\norchestrators, and the Front Desk can poll to decide *how* to talk to the operator\n— quick interactive turns during desk focus, async batching during idle, composer\nsilence during Bed Mode.\n\nBuilt on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.\n\n---\n\n## Table of contents\n\n1. [Overview & philosophy](#1-overview--philosophy)\n2. [Installation & accessing the surface](#2-installation--accessing-the-surface)\n3. [End-user guide](#3-end-user-guide)\n4. [Configuration](#4-configuration)\n5. [Data computation & mechanics](#5-data-computation--mechanics)\n6. [Fleet postures & directives](#6-fleet-postures--directives)\n7. [RPC contracts & schema reference](#7-rpc-contracts--schema-reference)\n8. [Runtime state & file map](#8-runtime-state--file-map)\n9. [Identified gaps & TODOs](#9-identified-gaps--todos)\n10. [Development](#10-development)\n11. [License](#11-license)\n\n---\n\n## 1. Overview & philosophy\n\nThe fleet does not need an LLM to know the operator is tired. It needs arithmetic.\n\nMost \"presence aware\" tooling either (a) fires an LLM call to summarise user\nactivity, or (b) hard-codes wall-clock schedules. `wellbeing` does neither. It is a\nsmall, pure state machine — `PresenceTracker` — that ingests timestamped activity\npulses, persists a few counters to disk, and derives an **operator phase** and a\n**fleet posture** from deterministic math over those timestamps and the operator's\nconfigured circadian windows.\n\nThree design commitments follow from this:\n\n- **The daemon is the source of truth.** Presence is computed server-side in\n  `index.server.ts`, not in the client. The client is a thin telemetry emitter and\n  renderer; if Paseo Desktop is closed, the daemon still tracks the last-known\n  activity and the circadian clock still advances.\n- **Silence is a feature, not an accident.** The output of the system is a\n  *directive string* broadcast to the rest of the fleet. When the operator is in\n  Bed Mode the directive is to maintain composer silence and escalate only\n  `priority/0-SOS`. The point is to stop the fleet interrupting a resting human.\n- **Fatigue is a circuit breaker, not a nag.** A single threshold\n  (`maxSessionContinuousMinutes`), a cooldown (`fatigueAlertCooldownMinutes`), and\n  a snooze window gate exactly one outbound notification per breach. The system is\n  designed to be quiet by default and loud only when a human is genuinely at risk\n  of grinding.\n\n> The model is intentionally *small and legible*. Everything described in\n> [§5](#5-data-computation--mechanics) is implemented in\n> [`server/presence.ts`](server/presence.ts) — under 400 lines, no dependencies\n> beyond Node's `fs` and `child_process`.\n\n---\n\n## 2. Installation & accessing the surface\n\n### Requirements\n\n- Paseo `>=0.8.0` (declared in [`paseo-plugin.json`](paseo-plugin.json)).\n\n### Installing it\n\nInstall from npm:\n\n```sh\npaseo plugin add npm:@xpufx/paseo-wellbeing\n```\n\nOr install directly from the Git repository:\n\n```sh\npaseo plugin add xpufx/paseo --path plugins/wellbeing\n```\n\nThen reload the daemon. On load, `index.server.ts` logs:\n\n```\nwellbeing plugin contributed: operator presence tracking & circadian wind-down live\n```\n\n### Opening the Wellbeing sidebar\n\nThe client half (`index.client.tsx`) registers a single sidebar surface:\n\n| Property | Value |\n| --- | --- |\n| Surface id | `wellbeing` |\n| Title | `Wellbeing` |\n| Icon | `Heart` |\n| Flair | `rounded` radius, `comfortable` density, `elevated` surface, 1px border |\n\nIn Paseo Desktop, open the **Wellbeing** item in the sidebar (the heart icon).\nThe surface opens instantly with a loading state — *\"Loading operator presence\ntelemetry…\"* — then renders the live dashboard. The status query auto-refreshes\nevery **5 seconds** (`refetchInterval: 5000`) while the surface is mounted.\n\n> The surface does **not** need to be open for *core* tracking to work: the daemon\n> runs its own 60 s heartbeat (see [§5.6](#56-fatigue-circuit-breaker--notifications))\n> regardless of what the UI is showing. The richer client-side telemetry\n> (`pointerdown` / `keydown` / `focus` listeners and the 30 s visibility\n> heartbeat) only runs while the surface component is mounted, since those\n> listeners are registered inside `WellbeingSurface`. The surface is a viewer plus\n> manual controls.\n\n---\n\n## 3. End-user guide\n\nThe surface is a single scroll-free dashboard with four regions:\n\n```\n┌──────────────────────────────────────────────────────────┐\n│  Operator Wellbeing                 [ DESK FOCUS ]        │  ← phase badge\n│  Telemetry: UI Touch/Keyboard                             │\n├──────────────────────────────────────────────────────────┤\n│  Continuous Active Stretch               Limit: 180m      │\n│  47 min                                                   │\n│  ▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░░░░░░░░░                      │  ← progress bar\n│  ┌ ⚠️ Unbroken focus exceeds healthy limits. ───────────┐ │\n│  │  💤 Snooze 15m                                       │ │  ← only in fatigue\n│  └──────────────────────────────────────────────────────┘ │\n├──────────────────────────┬───────────────────────────────┤\n│  Total Active Today      │  Breaks Taken                 │\n│  3h 12m                  │  4                            │\n│  Window: 09:00–18:00     │  Longest: 96m                 │\n├──────────────────────────┴───────────────────────────────┤\n│  Fleet Posture Directive                                  │\n│  Operator Status: Active (Desk Focus). …                  │\n│  🌙 Wind-Down: 22:30            ☀️ Wake: 07:30             │\n├──────────────────────────┬───────────────────────────────┤\n│  🛌 Shift to Bed Mode     │  ⚡ Log Focus                 │\n└──────────────────────────┴───────────────────────────────┘\n```\n\n### 3.1 The phase badge\n\nTop-right. Colour and label are driven by `status.phase`:\n\n| Phase | Badge label | Meaning |\n| --- | --- | --- |\n| `working` | **DESK FOCUS** | Operator is active and within a normal working stretch. |\n| `extended-stretch` | **FATIGUE ALERT** | Unbroken active stretch exceeded the configured limit and is not snoozed. |\n| `wind-down` | **WIND-DOWN** | Inside the wind-down→wake window; the operator should be winding down. |\n| `bed-mode` | **BED MODE** | Manual override is on, or the circadian wind-down window auto-engaged Bed Mode. |\n| `idle` | **AWAY** | No activity for longer than `idleTimeoutMinutes`. |\n\nThe sub-label *\"Telemetry: …\"* shows the **last activity source**, humanised:\n\n| Source | Displayed as |\n| --- | --- |\n| `client_surface` | Surface Active |\n| `client_interaction` | UI Touch/Keyboard |\n| `interactive_turn` | Prompt Interaction |\n| `permission_resolved` | Permission Decision |\n| `manual_override` | Manual Pulse |\n\nIf no activity has ever been recorded it reads *\"Telemetry: Standby\"*.\n\n### 3.2 Continuous Active Stretch card\n\nShows `activeStretchMinutes` (rounded), the configured limit\n(`maxSessionContinuousMinutes`), and a progress bar. Bar thresholds: **warning at\n75 %**, **danger at 100 %**, auto-coloured by the shared `ProgressBar`.\n\nWhen the phase is `extended-stretch`, an inline alert box appears:\n\n> ⚠️ Unbroken focus exceeds healthy limits. Take a macro-break!\n\nwith a **💤 Snooze 15m** button. Pressing it calls\n`wellbeing.snooze_alert { minutes: 15 }`, which suppresses fatigue alerts and\ndemotes the phase out of `extended-stretch` for 15 minutes. The alert box\ndisappears as soon as the phase moves off `extended-stretch`.\n\n### 3.3 Daily metrics grid (2×2)\n\n| Tile | Source field | Notes |\n| --- | --- | --- |\n| **Total Active Today** | `dailyUsageMinutes` | Rendered as `Xh Ym`. Footer shows the configured `workingHours` window (display only — see gap [T7](#9-identified-gaps--todos)). |\n| **Breaks Taken** | `breaksTaken` | Footer shows `longestStretchMinutes`. |\n\nBoth counters reset at local midnight (see [§5.2](#52-daily-usage--breaks)).\n\n### 3.4 Fleet Posture Directive box\n\nRenders `fleetDirective` verbatim, plus the configured `windDownTime` / `wakeUpTime`.\nThis is the exact instruction string that agents and orchestrators read from\n`wellbeing.status`. See [§6](#6-fleet-postures--directives) for the five postures.\n\n### 3.5 Action controls\n\n| Control | Label when inactive | Label when Bed Mode active | RPC |\n| --- | --- | --- | --- |\n| Bed Mode toggle | **🛌 Shift to Bed Mode** | **🌙 Bed Mode Active (Resume)** | `wellbeing.toggle_bed_mode { enabled: !isBedMode }` |\n| Manual pulse | **⚡ Log Focus** | (unchanged) | `wellbeing.record_activity { source: \"manual_override\" }` |\n\n- **Shift to Bed Mode / Bed Mode Active (Resume)** — explicitly sets the manual\n  Bed Mode override to the opposite of the current state and immediately refetches\n  status. Activating Bed Mode also fires a 2fado notification if `notifyVia2fado`\n  is enabled (see [§5.6](#56-fatigue-circuit-breaker--notifications)).\n- **Log Focus (⚡)** — records a manual activity pulse. Use it when you are working\n  but the automatic detectors can't see it (e.g. reading on another screen). It\n  updates `lastActivityAt`, `lastActivitySource = manual_override`, and feeds the\n  same streak/break/fatigue math as any other pulse.\n\n---\n\n## 4. Configuration\n\nSettings are persisted through the standard `paseo-plugin-helper` settings\ncontract under the name `wellbeing.settings`, and are readable/writable via three\nRPCs (see [§7.5](#75-wellbeing-settings--settingsget--update--reset)).\n\n### 4.1 Settings fields\n\n| Field | Type | Default | Range / format | Purpose |\n| --- | --- | --- | --- | --- |\n| `workingHours.start` | `HH:MM` | `09:00` | `00:00`–`23:59` | Nominal work window. **Display only today** (gap [T7](#9-identified-gaps--todos)). |\n| `workingHours.end` | `HH:MM` | `18:00` | `00:00`–`23:59` | Same. |\n| `windDownTime` | `HH:MM` | `22:30` | `00:00`–`23:59` | Start of the wind-down / Bed Mode window. |\n| `wakeUpTime` | `HH:MM` | `07:30` | `00:00`–`23:59` | End of the wind-down / Bed Mode window (may cross midnight). |\n| `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)). |\n| `maxSessionContinuousMinutes` | `integer` | `180` | `15`–`720` | Continuous-stretch fatigue threshold. |\n| `idleTimeoutMinutes` | `integer` | `15` | `1`–`120` | Inactivity gap that counts as a break and resets the streak. |\n| `fatigueAlertCooldownMinutes` | `integer` | `60` | `5`–`360` | Minimum time between two fatigue notifications. |\n| `notifyVia2fado` | `boolean` | `true` | — | Forward fatigue / Bed Mode notices to 2fado mobile push. |\n\nAll fields are Zod-validated on write; invalid updates are rejected by the\ncontract. Updates are **partial merges** — send only the keys you want to change.\n\n### 4.2 Circadian window semantics\n\n`windDownTime → wakeUpTime` defines Bed Mode. It is evaluated by\n`isTimeInWindow(currentMinutes, start, end)`:\n\n- If `start <= end`, the window is the plain interval `[start, end]` (inclusive).\n- If `start > end`, the window **wraps past midnight**: `current >= start || current <= end`.\n\nSo `22:30 → 07:30` matches 22:30–23:59 **and** 00:00–07:30. Boundary minutes are\ninclusive. Times use the **daemon host's local clock**; there is no timezone field.\n\n### 4.3 Example: night owl\n\n```jsonc\n// wellbeing.settings.update\n{\n  \"workingHours\": { \"start\": \"12:00\", \"end\": \"22:00\" },\n  \"windDownTime\": \"01:00\",\n  \"wakeUpTime\": \"09:30\",\n  \"maxSessionContinuousMinutes\": 120,\n  \"idleTimeoutMinutes\": 10\n}\n```\n\nThis wraps Bed Mode across midnight (01:00 → 09:30) and tightens both the\ncontinuous-stretch and idle thresholds.\n\n---\n\n## 5. Data computation & mechanics\n\nAll logic lives in [`server/presence.ts`](server/presence.ts). The tracker holds a\nsingle JSON-serialisable state object and recomputes every derived value from it on\ndemand.\n\n### 5.1 Continuous active stretch (streaks, decay, breaks)\n\nThe tracker stores four relevant fields: `streakStartTs`, `lastActivityTs`,\n`longestStretchSeconds`, and `breaksTakenToday`.\n\n**First-ever pulse** (`lastActivityTs === null`):\n\n```\nstreakStartTs  = now\nlastActivityTs = now\nlastActivitySource = source\n```\n\nActive stretch is 0, since the streak has just begun.\n\n**Subsequent pulses** — let `idleMs = now - lastActivityTs`:\n\n- **If `idleMs > idleTimeoutMinutes * 60_000`** → the operator was away long\n  enough to count as a **break**:\n  ```\n  breaksTakenToday += 1\n  streakStartTs     = now      // streak restarts at this pulse\n  ```\n  Note: the idle gap itself is **not** credited to `dailyUsageSeconds`.\n- **Else if `idleMs > 0`** → the operator was continuously present, so accrue\n  usage, capped at the idle timeout:\n  ```\n  dailyUsageSeconds += min(idleMs, idleTimeoutMinutes * 60_000) / 1000\n  ```\n\nRegardless of branch, `lastActivityTs = now` and `lastActivitySource = source` are\nupdated. The current stretch is then:\n\n```ts\ngetActiveStretchMinutes(now):\n  if no streak/lastActivity            -> 0\n  if (now - lastActivityTs) > idleTimeout  -> 0    // streak has decayed\n  else -> (now - streakStartTs) / 60000\n```\n\nIf the current stretch (in seconds) exceeds `longestStretchSeconds`, the longest\nfigure is updated. **Idle interval decay is live**: a streak that has gone stale\nreports 0 even before the next pulse arrives, because `getActiveStretchMinutes`\nre-checks the idle gap against `idleTimeoutMinutes`.\n\n> **Important nuance:** the streak start is *not* reset by ordinary continuous\n> activity. A streak begins at the first pulse and only restarts when a pulse\n> arrives after a gap longer than `idleTimeoutMinutes`. This is what makes a\n> \"continuous active stretch\" different from \"wall-clock time since first event.\"\n\n### 5.2 Daily usage & breaks\n\nOn **every** `recordActivity` call the tracker computes today's local date stamp\n(`YYYY-MM-DD`) via `getTodayStamp`. If it differs from the stored `lastDayStamp`\n(i.e. local midnight has passed), it performs a rollover:\n\n```\ndailyUsageSeconds   = 0\nlongestStretchSeconds = 0\nbreaksTakenToday    = 0\nlastDayStamp        = today\nfatigueAlertCount   = 0\nlastFatigueAlertTs  = null\n```\n\n`dailyUsageMinutes` in status is `round(dailyUsageSeconds / 60)`. Because usage\nonly accrues on a pulse (and only for the interval since the previous pulse,\ncapped at `idleTimeoutMinutes`), the total **undercounts** time after the most\nrecent event and full idle gaps — see gaps [T4](#9-identified-gaps--todos) and\n[T5](#9-identified-gaps--todos).\n\n### 5.3 Circadian phase resolution\n\n`calculatePhase(now)` resolves in strict priority order. The first matching rule\nwins:\n\n| # | Condition | Phase |\n| --- | --- | --- |\n| 1 | `isBedModeActive(now)` | `bed-mode` |\n| 2 | `idleMinutes > idleTimeoutMinutes` | `idle` |\n| 3 | `activeStretchMinutes >= maxSessionContinuousMinutes` **and not snooze"}