Disclosure: I'm the developer of the tool described here, and this post was drafted and published by an AI agent from the project's source code. Relaymote is unofficial; not affiliated with or endorsed by Anthropic.
Relaymote (MIT, Node.js) is my phone remote for Claude Desktop's Code-tab sessions. Some of what it does on the desktop is UI automation: it drives the window to approve a prompt or paste a message. That is fine at 2 a.m. when nobody is there. It is not fine if it fires while you are typing into the same window, because your keystrokes and its keystrokes interleave into one scrambled mess.
So before any UI action, Relaymote asks one question: has the user provably been away for N seconds? This post is the small module that answers it, and the one rule that makes it safe.
The rule: unknown is not idle
The whole module hangs on a single decision. When you cannot tell whether the user is at the keyboard, the safe answer is "not idle" — never "idle". Guessing "idle" is exactly how a tool ends up typing into the window you are using. So every path that can't get a reading returns null, and null is treated as busy:
/** True only when the user has provably been away for minMs. Unknown is NOT idle. */
function isIdle(minMs) {
if (!(minMs > 0)) return true; // gate disabled → always allowed
const ms = idleMs();
return ms !== null && ms >= minMs; // null (unknown) fails this, so it blocks
}
That one !== null is the difference between a tool that waits politely and one that fights you for the keyboard.
Where the number comes from, per OS
"Idle time" means milliseconds since the last keyboard or mouse input, and every OS exposes it differently:
-
Windows —
GetLastInputInfo, a Win32 call. There's no Node binding, so one long-lived PowerShell child compiles a tiny helper withAdd-Typeand prints the idle ms every 400 ms. Node just reads lines. -
macOS —
ioreg -c IOHIDSystemprintsHIDIdleTimein nanoseconds; divide by 1e6 for ms. -
Linux —
xprintidleprints ms, when it's installed. If it isn't, there is no reading.
The Windows side runs a loop inside the PowerShell helper so Node isn't spawning a process per sample:
public class RelaymoteIdle {
[DllImport("user32.dll")] public static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
[DllImport("kernel32.dll")] public static extern uint GetTickCount();
public static uint Ms() {
LASTINPUTINFO l = new LASTINPUTINFO();
l.cbSize = (uint)Marshal.SizeOf(l);
GetLastInputInfo(ref l);
return GetTickCount() - l.dwTime; // ms since last input
}
}
# while ($true) { [Console]::Out.WriteLine([RelaymoteIdle]::Ms()); Start-Sleep -Milliseconds 400 }
The macOS and Linux readings are parsed defensively — anything that isn't a clean number is null, not zero:
function parseIoreg(text) {
const m = /"HIDIdleTime"\s*=\s*(\d+)/.exec(String(text || ''));
if (!m) return null;
const ms = Math.floor(Number(m[1]) / 1e6); // ns → ms
return Number.isFinite(ms) && ms >= 0 ? ms : null;
}
function parseXprintidle(text) {
const t = String(text || '').trim();
return /^\d+$/.test(t) ? Number(t) : null;
}
Staleness is also unknown
A cached reading is only trustworthy for a moment. If the PowerShell sampler dies, or the macOS probe stops answering, the last number keeps aging. Reporting a four-second-old "idle" reading as current would let an action through right after you came back. So a reading past its freshness window is thrown away and treated as unknown too:
function idleMs() {
start();
if (lastIdleMs === null) return null;
const age = Date.now() - lastAt;
if (age > STALE_MS /* + a poll interval off-Windows */) return null; // too old → unknown
return lastIdleMs + age; // add elapsed time so the answer is current, not when sampled
}
Note the + age: the sampler fired some milliseconds ago, and the user has been idle for that long on top, so the live answer is the sampled value plus how long since it was sampled.
Backoff, because a missing probe shouldn't become a fork bomb
On Linux without xprintidle, naively retrying means spawning a doomed process on every check. The module remembers an unavailable probe and won't retry it for 60 seconds, and off-Windows it polls at most once a second. When the probe is genuinely gone, the cost is one failed spawn a minute, not one per UI decision.
What you actually get
isIdle(minMs) for the gate, and a status() for the health endpoint and relaymote status that says which method is in use and whether it's answering:
{ platform: 'win32', method: 'GetLastInputInfo', known: true, idleMs: 83120, unavailable: false }
So a user can see why automation is or isn't firing, instead of guessing. In Relaymote the gate is a switch (Settings › Modules › Idle gate); set it to 0 and UI actions run with no gate at all, which is what you want on a dedicated machine with no human at it.
The portable lessons
- For a safety gate, make "I don't know" fail closed. The default must be the safe one, and uncertainty is not the safe one.
- A cached fact has an age. Past its freshness window it's a guess, so discard it rather than serve it as current.
- A probe that can be missing needs backoff, or your error path becomes a busy loop.
Relaymote is MIT-licensed: github.com/ashwarsadh/relaymote. Unofficial; not affiliated with or endorsed by Anthropic.
Top comments (0)