Skip to content

Linux: Idle sensing feature similar to HID idleness in macOS#56

Draft
joerybka wants to merge 9 commits into
CodeAbra:mainfrom
joerybka:feature/linux-idle-sensor
Draft

Linux: Idle sensing feature similar to HID idleness in macOS#56
joerybka wants to merge 9 commits into
CodeAbra:mainfrom
joerybka:feature/linux-idle-sensor

Conversation

@joerybka

@joerybka joerybka commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Updates the idle sensor to read from the logind service on a Linux machine. This aims to be agnostic between X11 and Wayland.

Type of change

  • Bug fix
  • New feature
  • Refactor (no behaviour change)
  • Documentation
  • Build / tooling

Affected areas

  • Capture path
  • Recall / retrieval
  • Consolidation / sleep cycles
  • Daemon lifecycle / FSM
  • Storage / encryption at rest
  • MCP wrapper (TypeScript)
  • Bench harness
  • CLI / doctor
  • Other: ___

Testing

  • pytest passes locally
  • ruff check src/ tests/ clean
  • New tests added for changed behaviour, or rationale below for why not

Benchmarks

This does not touch the active path of any benchmark-eligible change.

Notes for reviewers

This change updates the idle sensor for Linux platforms to use logind, mimicking how the enriched check on macOS checks for HID idleness.

logind Differences

The underlying theory of the way that logind works on Linux and how IOHIDSystem work on macOS is different. When querying IOReg on macOS, there is no need to scan for a session that is using a console, since there is only one console to worry about. logind is predicated on multi-session, so the code must scan for all sessions using a console (seatN) and then look for the lowest idle time to return for actual comparison. In truthfulness, this is me being extremely pedantic for completeness, the likelihood of this actually happening is very low.

The current implementation could be pulled back and simplified to look for the first seat found (usually seat0), and then just use the IdleHint from that console. Given the code already has to fall through the MCP heartbeat check to start polling for idleness, that would optimise it slightly, shaving about ~300 nanoseconds from the idle check. I already have it written the way it is though, so not sure if you want me to pull that back or not.

I looked into unifying the idle approach, since they are both Unix-based, and that is actually a worse result with the same split complexity, because looking up the active terminals is different across platforms.

joerybka and others added 9 commits July 5, 2026 15:25
macOS uses ioreg's HIDIdleTime and pmset; Linux had no equivalent, so
doctor's "(n) HID idle source" check and the daemon's sleep-eligibility
gate always fell back to heartbeat-idle only.

logind tracks IdleHint/IdleSinceHint from seat input activity independent
of the display server, so it works identically under X11, Wayland, or a
bare console with no guessing required. Query it via busctl (already a
systemd dependency of this project) using Manager.GetSessionByPID, which
is robust across systemd versions unlike the newer "/session/self"
object-path shortcut.

Added IdleDetector.os_idle_time_sec() as a platform dispatcher so callers
(sleep_eligible(), status()) no longer need their own OS checks -- it
senses platform.system() and delegates to the right backend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
GetSessionByPID(getpid()) resolves the *calling process's* own session --
but the daemon runs as a systemd user service, not attached to any
interactive session's cgroup, so this lookup always fails in the one
context that actually matters (verified live: "PID N does not belong to
any known session"). Enumerate ListSessions instead and pick the caller's
own uid's seat-attached (non-headless) session.

Also switch to `busctl --json=short` throughout instead of regex-scraping
the human-readable table output -- more robust, and avoids ambiguity in
how busctl formats multi-entry arrays.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
idle_str and logind_str differed only in their None fallback text
("unavailable" vs "not idle") but were computed unconditionally at the
top regardless of which branch actually used them. Move each into the
branch where its fallback text applies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extract idle_str() as a small helper -- the only thing that actually
varies between the logind and HIDIdleTime paths is the None fallback
label ("not idle" vs "unavailable"). detail/status are now computed
per-branch but the CheckResult construction itself happens once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
detail and result_status are fully overwritten in every branch, so the
nested else added indentation without sharing anything. signals_str and
pmset_str are hoisted once; idle_str's fallback label is the only thing
that actually varies per branch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cosmetic reorder -- the two are mutually exclusive per-platform signals,
so this doesn't change behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ribe()

check_n_hid_idle_source() branched on "HIDIdleTime"/"logind" string
membership to pick its wording -- exactly the platform check
os_idle_time_sec()'s docstring claimed callers wouldn't need. Moved that
formatting into a new IdleDetector.describe() -> (detail, status) method,
next to the platform dispatch that already knows about these backends.
The doctor check is now a pure pass-through with zero backend-name
knowledge of its own.

Also tightened os_idle_time_sec()'s docstring: the "no platform checks"
guarantee applies to consumers that only use idle_seconds (sleep_eligible);
source_name is a diagnostic label for reporting consumers, and
source-name-specific formatting belongs in this module, not a caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously picked the first uid+seat match from ListSessions and reported
only that session's idle state. On real multi-seat hardware this risks
reading the wrong seat's IdleHint while the user is actively working on a
different seat. Now every seat-attached session for the uid is checked:
overall idle requires all of them idle (any active seat means not idle),
and the reported duration is the minimum across idle seats -- the most
conservative estimate, since one seat may have gone idle much more
recently than another.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ectly

logind_idle_time_sec() was only ever called by tests -- os_idle_time_sec()
(the actual dispatcher) inlines the equivalent two calls itself to avoid
enumerating sessions twice. Unlike hid_idle_time_sec(), which the Darwin
branch does call directly, there was no real parity to preserve by keeping
a Linux method the production path never used. Retargeted its tests to
_logind_aggregate_idle(), which is what os_idle_time_sec() actually calls,
and dropped the redundant "session not found" case already covered by
_logind_session_paths()'s own empty-list tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@joerybka

joerybka commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

@CodeAbra I have left this in draft, since I am looking for feedback on whether to simplify the idle check for the more common single-console case that this would likely be deployed with.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant