@xpufx/paseo-wellbeing
v0.2.2
Published
Operator presence tracking, circadian schedule management, and fatigue/wind-down alerting for Paseo
Readme
@xpufx/paseo-wellbeing
Operator presence tracking, circadian schedule management, and fatigue / wind-down alerting for Paseo. A deterministic mathematical model that runs entirely inside the Paseo daemon event loop — zero LLM tokens, zero model calls, zero network round-trips.
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:
- Is the operator actually here? (presence / idle / away)
- How long have they been heads-down without a real break? (continuous active stretch)
- 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, the shared Paseo plugin runtime.
Table of contents
- Overview & philosophy
- Installation & accessing the surface
- End-user guide
- Configuration
- Data computation & mechanics
- Fleet postures & directives
- RPC contracts & schema reference
- Runtime state & file map
- Identified gaps & TODOs
- Development
- 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 is implemented in
server/presence.ts— under 400 lines, no dependencies beyond Node'sfsandchild_process.
2. Installation & accessing the surface
Requirements
- Paseo
>=0.8.0(declared inpaseo-plugin.json).
Installing it
Install from npm:
paseo plugin add npm:@xpufx/paseo-wellbeingOr install directly from the Git repository:
paseo plugin add xpufx/paseo --path plugins/wellbeingThen reload the daemon. On load, index.server.ts logs:
wellbeing plugin contributed: operator presence tracking & circadian wind-down liveOpening 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) regardless of what the UI is showing. The richer client-side telemetry (
pointerdown/keydown/focuslisteners and the 30 s visibility heartbeat) only runs while the surface component is mounted, since those listeners are registered insideWellbeingSurface. 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). |
| Breaks Taken | breaksTaken | Footer shows longestStretchMinutes. |
Both counters reset at local midnight (see §5.2).
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 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
notifyVia2fadois enabled (see §5.6). - 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 managed via upstream Paseo 0.9's native server.registerSettings()
handle (wellbeing definition, scoped to host), providing automatic read() and
reactive subscribe() change notifications directly to PresenceTracker (see §7.5).
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). |
| 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). |
| 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
// 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. 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 = sourceActive 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:
Note: the idle gap itself is not credited tobreaksTakenToday += 1 streakStartTs = now // streak restarts at this pulsedailyUsageSeconds. - 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:
getActiveStretchMinutes(now):
if no streak/lastActivity -> 0
if (now - lastActivityTs) > idleTimeout -> 0 // streak has decayed
else -> (now - streakStartTs) / 60000If 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 = nulldailyUsageMinutes 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 and
T5.
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 snoozed | extended-stretch |
| 4 | current local time inside windDownTime → wakeUpTime | wind-down |
| 5 | otherwise | working |
isBedModeActive(now) itself resolves as:
- If a manual override exists (
manualBedMode !== null) → return it. - Otherwise →
isTimeInWindow(now, windDownTime, wakeUpTime)— i.e. Bed Mode auto-engages inside the wind-down window.
Subtlety worth knowing: because rule 1 already claims the
windDownTime → wakeUpTimewindow when no override is set, thewind-downphase (rule 4) is only reachable when the manual override has been explicitly set tofalse. With the default configuration and no manual toggle, nights resolve tobed-mode, neverwind-down. See gap T2.
getIdleMinutes(now) is max(0, (now - lastActivityTs) / 60000), or 0 if no
activity has ever been recorded.
5.4 Activity telemetry sources
ActivitySource is a closed enum of five values. Each pulse records where it came
from, surfaced as lastActivitySource:
| Source | Emitted by | Trigger |
| --- | --- | --- |
| client_surface | Client | Initial heartbeat when the surface opens; a 30 s interval heartbeat while document.visibilityState === "visible". The daemon's 60 s fatigue heartbeat no longer emits this (it calls read-only evaluateFatigue() — see gap T3). |
| client_interaction | Client | pointerdown, keydown, and focus window events (throttled to one pulse per 15 s). |
| interactive_turn | Server | agent.turn_ended where the timeline contains a user_message whose clientMessageId is absent or does not start with cron_ (i.e. a real human prompt, not a scheduled one). |
| permission_resolved | Server | agent.permission_resolved — the operator acted on a permission prompt. |
| manual_override | Client | The ⚡ Log Focus button. |
Client-side throttling (lastHeartbeatRef, 15 s) means pointer/key/focus bursts
coalesce into at most one record_activity call per 15 seconds. Every client pulse
triggers a status refetch so the UI stays current.
5.5 Fatigue circuit breaker
Evaluated via the private maybeTriggerFatigueAlert helper, called both inside
recordActivity after the streak/usage update and from the read-only
evaluateFatigue() heartbeat path (which advances only the cooldown bookkeeping):
isSnoozed = snoozedUntilTs !== null && now < snoozedUntilTs
if (activeStretchMinutes >= maxSessionContinuousMinutes && !isSnoozed):
canAlert = lastFatigueAlertTs === null
|| (now - lastFatigueAlertTs) >= fatigueAlertCooldownMinutes * 60000
if canAlert:
lastFatigueAlertTs = now
fatigueAlertCount += 1
fatigueAlertTriggered = trueKey properties:
- The snooze suppresses both the alert and the
extended-stretchphase, sincecalculatePhaseandgetFleetPostureboth checkisSnoozed. - The cooldown prevents re-alerting on every subsequent pulse after a breach.
fatigueAlertCountresets on the daily rollover.
fatigueAlertTriggered in WellbeingStatus is derived — it is
phase === "extended-stretch" rather than the raw per-call flag. During a cooldown
window (still fatigued but already alerted), status will report
fatigueAlertTriggered: true even though no new notification was dispatched. See
gap T9.
5.6 Fatigue circuit breaker & notifications
When a fatigue alert fires and notifyVia2fado is enabled,
index.server.ts dispatches:
2fado notify --summary "⚠️ Operator Wellbeing: Continuous high-intensity session reached Nm. Consider taking a break or enabling Bed Mode." --link http://localhost:3000via execFile (send2fadoNotice). Success/failure is swallowed — the plugin never
throws on a notification failure.
There are three notification sites:
- Manual/auto Bed Mode activation (in the
toggle_bed_modehandler) —"🌙 Bed Mode Activated: Fleet in custodial off-hours posture. Interactive queries deferred to morning." - First fatigue breach — dispatched from the
record_activityhandler whenrecordActivityreturnsfatigueAlertTriggered: true. - Daemon heartbeat — every 60 s (
setInterval), if the current phase isextended-stretchandnotifyVia2fadois on, the daemon calls the read-onlyPresenceTracker.evaluateFatigue()and, if that call produced an alert, sends the same fatigue notice. The heartbeat is cleared on plugin teardown.
The heartbeat exists to catch the case where no further client pulses arrive while the operator remains fatigued.
evaluateFatigue()is read-only: it recomputes the active stretch from existing state and only advances the fatigue-alert cooldown, so it never refresheslastActivityTs, accruesdailyUsageSeconds, or extends the streak. This means a genuinely idle operator reachesidleafteridleTimeoutMinuteseven with the ticker running. See gap T3 (resolved).
5.7 Persistence & migration
Two distinct files are involved.
Settings (via PluginStorage):
~/.paseo/plugin-data/xpufx/wellbeing/settings.jsonPresence state (managed directly by PresenceTracker):
canonical: ~/.paseo/plugin-data/xpufx/wellbeing/wellbeing-state.json
legacy: ~/.config/paseo/wellbeing-state.jsonResolution at construction time (PresenceTracker):
- If an explicit
stateFilePathoption is provided (tests) → use it. - Else if the canonical file exists → use it.
- Else if the legacy file exists → copy it to the canonical location (creating parent directories), then use the canonical path; if the copy fails, fall back to reading/writing the legacy path in place.
- Else → use the canonical path (file created on first save).
Writes are atomic: state is serialised to <path>.tmp.<timestamp>, then
renamed over the target. All disk errors are swallowed — persistence is
best-effort and never blocks the event loop.
Legacy migration is covered by a regression test (#446) in
server/presence.test.ts.
5.8 Persisted state shape
PresenceStateData (the on-disk JSON):
| Field | Type | Meaning |
| --- | --- | --- |
| lastActivityTs | number \| null | Epoch ms of the last pulse. |
| lastActivitySource | ActivitySource \| null | Source of the last pulse. |
| streakStartTs | number \| null | Epoch ms the current streak began. |
| longestStretchSeconds | number | Longest continuous stretch today (seconds). |
| breaksTakenToday | number | Breaks recorded since local midnight. |
| snoozedUntilTs | number \| null | Snooze expiry, epoch ms. |
| dailyUsageSeconds | number | Accumulated active seconds today. |
| lastDayStamp | string | YYYY-MM-DD stamp of the current day. |
| lastFatigueAlertTs | number \| null | Last alert dispatch, epoch ms. |
| fatigueAlertCount | number | Alerts dispatched today. |
| manualBedMode | boolean \| null | null = automatic (circadian); true/false = sticky override. |
6. Fleet postures & directives
getFleetPosture(now) mirrors the phase resolution but with its own priority order
and names:
| # | Condition | Posture |
| --- | --- | --- |
| 1 | Bed Mode active | bed-mode-custodial |
| 2 | idleMinutes > idleTimeoutMinutes | idle-standby |
| 3 | activeStretchMinutes >= maxSessionContinuousMinutes and not snoozed | extended-stretch |
| 4 | current time inside windDownTime → wakeUpTime | wind-down |
| 5 | otherwise | active-focus |
getFleetDirective(now) maps posture → instruction string. These strings are the
contract by which the rest of the fleet is expected to adjust behaviour:
| Posture | Directive |
| --- | --- |
| bed-mode-custodial | "Operator Status: Bed Mode (Rest/Mobile). Front Desk holds autonomous custody. Fleet maintains composer silence. Escalate ONLY priority/0-SOS to Front Desk." |
| wind-down | "Operator Status: Circadian Wind-Down. Prefer async batching and issue updates over interactive interruptions. Silence non-blockers." |
| extended-stretch | "Operator Status: Extended High-Intensity Session. Operator fatigue threshold exceeded; keep messages ultra-concise." |
| idle-standby | "Operator Status: Away / Idle. Batch non-urgent notifications." |
| active-focus | "Operator Status: Active (Desk Focus). Interactive prompts and quick turnarounds." |
How agents & orchestrators are expected to respond
active-focus— the normal mode. Interactive prompts and quick turnarounds are welcome.extended-stretch— the operator is fatigued. Keep messages ultra-concise; avoid open-ended questions.wind-down— prefer async batching and issue updates over blocking interaction. Silence non-blockers.bed-mode-custodial— the fleet maintains composer silence. Interactive queries are held for morning; onlypriority/0-SOSmay be escalated to the Front Desk. This is the posture that stops the fleet waking a sleeping operator.idle-standby— the operator is away. Batch non-urgent notifications.
Reality check: the directive is a string contract. The plugin broadcasts it, but there is currently no enforcement hook that intercepts composer submissions or muting in Bed Mode — consuming agents/orchestrators must honour it themselves. See gap T1.
7. RPC contracts & schema reference
All contracts are defined in shared/contracts.ts using
paseo-plugin-helper's defineContract / defineSettingsContract. Zod schemas
validate both input and output.
7.1 wellbeing.status
Current operator presence, continuous-session metrics, and circadian phase.
Input: {} (empty object)
Output — WellbeingStatus:
| Field | Type | Description |
| --- | --- | --- |
| phase | "working" \| "extended-stretch" \| "wind-down" \| "bed-mode" \| "idle" | Resolved operator phase. |
| fleetPosture | "active-focus" \| "extended-stretch" \| "wind-down" \| "bed-mode-custodial" \| "idle-standby" | Resolved fleet posture. |
| fleetDirective | string | Human/agent-readable directive (see §6). |
| isBedMode | boolean | Effective Bed Mode (manual override or circadian). |
| activeStretchMinutes | number | Current continuous stretch, rounded. 0 if decayed. |
| longestStretchMinutes | number | Longest stretch today, rounded. |
| breaksTaken | number | Breaks today. |
| idleMinutes | number | Minutes since last activity, rounded. |
| dailyUsageMinutes | number | Accumulated active minutes today, rounded. |
| lastActivityAt | string \| null | ISO-8601 timestamp of last pulse. |
| lastActivitySource | ActivitySource \| null | Source of last pulse. |
| streakStartedAt | string \| null | ISO-8601 start of current streak. |
| fatigueAlertTriggered | boolean | Derived: phase === "extended-stretch". |
| fatigueAlertCount | number | Alerts dispatched today. |
| snoozedUntil | string \| null | ISO-8601 snooze expiry. |
| settings | WellbeingSettings | Full effective settings snapshot. |
7.2 wellbeing.toggle_bed_mode
Toggle or explicitly set Bed Mode posture.
Input:
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| enabled | boolean \| undefined | no | Explicit target. Omit to invert the effective current state. |
Output:
| Field | Type | Description |
| --- | --- | --- |
| isBedMode | boolean | Effective Bed Mode after the call. |
| phase | OperatorPhase | Recomputed phase. |
| fleetPosture | FleetPosture | Recomputed posture. |
| fleetDirective | string | Recomputed directive. |
Side effects: sets the sticky manualBedMode override; persists state; if the
result is Bed Mode and notifyVia2fado is on, dispatches a 2fado notice. Once
set, the override persists across restarts and is not cleared by the circadian
clock (see gap T2).
7.3 wellbeing.record_activity
Record a human-operator or interactive-client activity pulse.
Input:
| Field | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| source | ActivitySource \| undefined | no | client_interaction | Where the pulse came from. |
Output:
| Field | Type | Description |
| --- | --- | --- |
| ok | boolean | Always true on success. |
| activeStretchMinutes | number | Rounded stretch after the pulse. |
| source | ActivitySource | Echoed source. |
Side effects: daily rollover if needed; streak/break/usage update; longest stretch update; fatigue evaluation; state persistence; 2fado notice on a fresh breach.
7.4 wellbeing.snooze_alert
Snooze fatigue alerts.
Input:
| Field | Type | Required | Default | Range |
| --- | --- | --- | --- | --- |
| minutes | integer | no | 15 | 1–180 |
Output:
| Field | Type | Description |
| --- | --- | --- |
| ok | boolean | Always true. |
| snoozedUntil | string | ISO-8601 expiry (now + minutes). |
Side effects: sets snoozedUntilTs, persists state, and (while active)
suppresses both extended-stretch phases and fatigue alerts.
7.5 Native Settings Handle & settings.wellbeing.* RPCs
Registered via server.registerSettings(wellbeingSettingsDefinition) (ID wellbeing).
Upstream Paseo 0.9 automatically provides native host RPCs (settings.wellbeing.read,
write, reset) and persists the host configuration document.
On the daemon server, WellbeingSettingsHandle captures the PluginSettings handle
returned by registerSettings():
- Baseline read: On startup,
handle.read()reads the initial settings and seedstracker.updateSettings(). - Change subscription:
handle.subscribe()listens for changes and updatesPresenceTrackerimmediately whenever settings are updated from client surfaces or host settings screens. - Graceful degradation: If running on Paseo < 0.9 (where
registerSettingsreturnedvoid), the server degrades cleanly to in-memory defaults without crashing.
The client surface reads settings from the
wellbeing.statuspayload, so the UI and daemon presence calculations are always consistent.
8. Runtime state & file map
| Path | Contents | Managed by |
| --- | --- | --- |
| ~/.paseo/plugin-settings/wellbeing/wellbeing.json | Host user settings (WellbeingSettings). | Upstream Paseo registerSettings |
| ~/.paseo/plugin-data/xpufx/wellbeing/wellbeing-state.json | Presence counters & timestamps (PresenceStateData). | PresenceTracker (atomic writes) |
| ~/.config/paseo/wellbeing-state.json | Legacy state; auto-migrated once to the canonical path. | PresenceTracker (read-only after migration) |
Source layout:
plugins/wellbeing/
├── index.server.ts # daemon entry: native settings registration, RPC handlers, event wiring, heartbeat
├── index.client.tsx # client entry: initClientHelpers + sidebar surface registration
├── client/
│ ├── surface.tsx # WellbeingSurface React component (dashboard + controls)
│ └── entry.test.ts # static test: entry injects required host deps
├── server/
│ ├── presence.ts # PresenceTracker: the deterministic model
│ ├── presence.test.ts # unit tests: circadian math, streaks, fatigue, bed mode, migration
│ ├── settings.ts # WellbeingSettingsHandle: native upstream 0.9 settings handle
│ └── settings.test.ts # unit tests: handle initialization, read, and change notification
├── shared/contracts.ts # Zod schemas + RPC & settings definition
├── paseo-plugin.json # plugin id + Paseo version requirement
└── package.json # workspace package, test/typecheck scripts9. Identified gaps & TODOs
These are known limitations of the current implementation, in rough priority order. They are not bugs fixed elsewhere — they are candidates for future work. Each is annotated with the file it would touch.
- [ ] T1 — Fleet directive has no enforcement hook.
fleetDirectiveis broadcast as a string; nothing in the plugin intercepts composer submissions, mutes notifications, or blocks turns while inbed-mode-custodial. The "composer silence" guarantee depends entirely on consuming agents honouring the directive. (index.server.ts, cross-plugin integration.) - [ ] T2 — Manual Bed Mode override is sticky and cannot be returned to auto.
Once
toggle_bed_modeis called,manualBedModeis non-nullforever and the circadian auto-Bed window is bypassed permanently (and thewind-downphase / posture becomes unreachable). There is no RPC to clear the override back to"auto";settings.updateonly seeds it when it is alreadynull. Add a tri-state option or awellbeing.clear_bed_mode_overrideRPC. (shared/contracts.ts,server/presence.ts.) - [x] T3 — 60 s heartbeat feeds
client_surfaceactivity, potentially masking genuine idle. Resolved (#562). The fatigue heartbeat now calls the read-onlyPresenceTracker.evaluateFatigue(), which recomputes the active stretch without refreshinglastActivityTsor accruingdailyUsageSeconds.recordActivityis reserved for genuine activity sources (client_surfaceRPC,interactive_turn,permission_resolved), so genuine idle now elapses andextended-stretchterminates as expected. (index.server.ts,server/presence.ts.) - [ ] T4 —
dailyUsageSecondsundercounts. Usage only accrues on the interval between consecutive pulses, and each interval is clamped toidleTimeoutMinutes. Time after the last pulse, and full idle gaps (which are breaks and credited zero), are never added. Daily totals are therefore a lower bound. (server/presence.ts.) - [ ] T5 — Streak decay is computed lazily on read. There is no background
tick that resets
streakStartTswhen a streak goes stale; the value is only zeroed bygetActiveStretchMinutes/getFleetPosturere-checking the idle gap. This is correct but means the persistedstreakStartTscan reference a long-dead streak until the next pulse. (server/presence.ts.) - [ ] T6 — No historical analytics, trends, or export. State keeps only
today's counters plus last-alert timestamps. There is no rolling-day history, no
weekly fatigue trend, and no export/
wellbeing.historyRPC. (server/presence.ts,shared/contracts.ts.) - [ ] T7 —
workingHoursis display-only. It renders in the UI but does not participate in phase, posture, or fatigue math. Either wire it into a "within working hours" signal or document/remove it. (server/presence.ts,client/surface.tsx.) - [ ] T8 — No multi-device presence sync.
manualBedMode, streaks, and usage are local to each daemon host's~/.paseodirectory. Working in Paseo Desktop on two machines produces two independent, divergent presence models. (server/presence.ts, requires a sync/transport design.) - [ ] T9 —
fatigueAlertTriggeredin status is derived, not event-accurate.getStatusreports it asphase === "extended-stretch", so it istruethroughout a cooldown even when no notification was sent for that poll. Distinguish "currently fatigued" from "alert just dispatched". (server/presence.ts.) - [ ] T10 — No in-app fatigue surface beyond the inline card. Alerts go
only to 2fado; there is no Paseo toast, modal, or
AttentionBeaconwhen the operator is looking at a different surface. (client/surface.tsx.) - [ ] T11 — No Pomodoro / work-rest interval scheduling. The model tracks
a single continuous stretch and threshold; it does not enforce or suggest
repeating focus/break intervals. (
server/presence.ts,shared/contracts.ts.) - [ ] T12 — Version drift.
WELLBEING_VERSIONinshared/contracts.tsis0.2.1whilepackage.jsonis0.1.0. Reconcile the two (ideally generate one from the other). (shared/contracts.ts,package.json.) - [ ] T13 — No automated bed-mode scheduling. Bed Mode only engages via the
manual toggle or the fixed
windDownTimewindow; there is no "auto-enable after N minutes idle" or calendar-driven scheduling. (server/presence.ts.) - [ ] T14 — Timezone is implicit. All window math uses the daemon host's
local clock; there is no configurable timezone for travelling operators or
remote daemons. (
server/presence.ts,shared/contracts.ts.) - [ ] T15 — Heartbeat and notification paths are untested. Unit tests cover
PresenceTrackerthoroughly, but the 60 s heartbeat, 2fado dispatch, and event wiring inindex.server.tshave no coverage. (index.server.tstests.)
10. Development
# Typecheck + unit tests (presence model, circadian math, migration, entry contract)
npm test --workspace=plugins/wellbeing
# Typecheck only
npm run typecheck --workspace=plugins/wellbeingThe test suite is executed with node:test via tsx and asserts:
parseMinutes/isTimeInWindow, including midnight-wrapping windows.- Streak tracking and source attribution.
- Break recording and streak reset after idle-timeout gaps.
- Fatigue-threshold triggering and snooze suppression.
- Background
evaluateFatigue()checks leavelastActivityTs, usage, and streak state untouched, andextended-stretchstill transitions toidleafter the idle timeout while the 60 s ticker runs (#562). - Manual Bed Mode toggling and fleet directives (including the
priority/0-SOSescalation text). - Legacy
~/.config/paseo→ canonical~/.paseostate migration (#446). - Static verification that
index.client.tsxinjects the required host deps.
When adding behaviour, extend PresenceTracker with pure, injectable now
parameters (as the existing methods do) so the model stays testable without
mocking the clock.
11. License
MIT — see LICENSE.
