These are design notes, not a release certification. The README describes supported use and recovery limits; the release validation record records the real-Mac checks each release passed, and Releasing lists the checks to run before the next one. Historical model sketches and UI proposals below are not exhaustive descriptions of the current code.
Insomnia is a menu bar app for timed awake sessions on a MacBook, used on a stable, well-ventilated surface. It must not be used to keep a Mac awake in a closed bag. Its design goals are to:
- Prevent lid-close sleep for a fixed, user-chosen duration. Never a toggle.
- Reduce battery waste while the lid is closed without slowing the agents.
- Shorten Wi-Fi to hotspot handoffs so agent API retries succeed.
- Journal changes and support recovery after interruption, while reporting incomplete restoration rather than promising unconditional success.
- Not a kernel extension, not a signed privileged helper, not a notarized download. Built from source, ad-hoc signed, installed by a script.
- Does not manage or restart the agents themselves. The only agent interaction is an optional "continue" keystroke into tagged tmux panes.
- Does not touch sleep behaviour outside an active session.
- Prefer OS events for live observations; recovery retries and countdown redraws require bounded recurring work.
- macOS 26 on Apple Silicon (built and tested on MacBook Pro M5).
- Swift 6, SwiftUI content hosted in a custom
NSStatusItem, Swift Package. No Xcode project. build-app.shassembles a minimalInsomnia.appbundle (LSUIElement = true, no Dock icon) withbackstop.shsealed underContents/Resourcesand signs it ad-hoc.install.shinstalls that build, or a prebuilt bundle passed with--appafter verifying its integrity (only with--allow-unverified-origin, since it cannot verify where a bundle came from), to~/Applications. The Release workflow packages the same bundle (docs/releasing.md), built for arm64 only;install.sh --appstops unlesssysctl -n hw.optional.arm64reads 1 (true on Apple Silicon, also under Rosetta).install.shanduninstall.shtake sibling scripts (build-app.sh,backstop.sh) only from a source checkout'sscripts/folder, withPackage.swiftone level up, and never from the folder above their own: the release zip carries both scripts at its top level, unpacked wherever the user chose, such as/tmp, where another account may have created that folder first and added files to it. Anywhere else,install.shwithout--appstops and runs nobuild-app.shit finds, anduninstall.shruns only the verified bundle's sealedbackstop.sh, or stops when the bundle has none. The staged copy of the bundle loses group and other write permission and every ACL before it is verified and installed (neither is part of the signature); extended attributes, the quarantine flag among them, stay.
Session {
startedAt: Date
endsAt: Date // the only thing that keeps sleep disabled
extendedBy: [TimeInterval]
}
RuntimeState { // everything Insomnia changed and must undo
sleepDisabledByUs: Bool
lowPowerSetByUs: Bool
frozenProcesses: [{pid, startedAt, startedAtMicros, bootSession}]
dockerFrozen: Bool
savedAudioOutputs: [{deviceUID, name?, volume, muted, saveID?}] // each output device a lid close muted, with what it had; saveID is a UUID drawn for each save; empty when mute is off or every device is restored
savedOutputVolume: Float? // legacy: an earlier build's entry, restored on the default output; never written now
savedMuted: Bool? // legacy, the same
savedDisplayBrightness: Float? // nil when darkening is off or lid is open
savedKeyboardBrightness: Float? // nil when there is no backlight, too
displayRestoreRefused: Bool // written only when true: the guard refused this restore; see section 4
keyboardRestoreRefused: Bool // the same for the keyboard backlight
displayRestoredUnderLowPower: Float? // restored on open under our Low Power Mode; written again when it ends
keptDisplayUnderLowPower: Float? // the kept (refused) display entry our Low Power Mode was on over; see section 4
keptDisplayUnderLowPowerBoot: String? // kern.bootsessionuuid of the boot that record was written in, or that backstop.sh switches the mode off in (published before it does)
keptDisplayReadLit: Float? // the kept display entry, read above 0 with the lid open and the panel awake; see section 4
appNapOverrides: [{bundleId, previous?}] // previous absent when the app had no NSAppSleepDisabled key
}
Both are written to ~/Library/Application Support/Insomnia/ as JSON on every
change. They are the source of truth for reconcile and for the backstop.
Legacy frozenPids entries lack ownership identity and need conservative
recovery; newly written journals use frozenProcesses.
- Time is entered inline in the menu bar as Days / Hours / Minutes pills. Enter with empty fields uses the configured default preset. Maximum 30 days.
- While active the menu bar shows a second-resolution countdown. The redraw timer runs at 1 Hz and stops while the lid is closed.
- Click the cup/countdown to enter an extension; hold the end control to end. Right-click opens the status, browser actions, Settings, and Quit menu.
- Session start: write session + state to disk, arm the launchd backstop, and
only then run
sudo pmset -a disablesleep 1. A session never starts unless the backstop is armed. If pmset fails, delete the session file and surface the error. The journal and backstop always exist before sleep is disabled. - Session end (timer, End now, Quit, battery floor, thermal critical):
sudo pmset -a disablesleep 0, undo every RuntimeState entry, delete session, notify. - Quitting Insomnia always ends the session. There is no "keep awake after quit".
install.shwrites/etc/sudoers.d/insomniaallowing the user to run, without a password, exactly:/usr/bin/pmset -a disablesleep 1/usr/bin/pmset -a disablesleep 0/usr/bin/pmset -b lowpowermode 1/usr/bin/pmset -b lowpowermode 0
- Nothing else runs as root.
- IOKit interest notification on
IOPMrootDomainforAppleClamshellState. Insomnia sleeps in its run loop; the kernel wakes it on change. Zero cost between events. - 2-second debounce to ignore flapping.
- Lid close and open each run a fixed, reversible action list (below).
- Lid events do nothing when no session is active.
- Each lid event is numbered as it arrives, before its actions queue behind
earlier ones. An open makes every earlier close stale. A close still
queued does nothing when its turn comes. A close waiting on a Docker probe
stops waiting at once, leaves Docker running, takes Docker's entries out
of the journal and signals nothing more, so the open's undo runs without
waiting for
docker ps. The probe's late answer is logged and not used.
All actions are recorded in RuntimeState and reversed on lid open, session end, Quit, or reconcile.
| action | on close | on open |
|---|---|---|
| Display (optional, default on) | save brightness, set it to 0, request display sleep (best effort) | wake the display, restore the saved brightness |
| Keyboard backlight (optional, same toggle) | save brightness, set it to 0 | restore the saved brightness |
| Freeze scope | SIGSTOP every process whose responsible app is in the freeze scope (rules below) |
SIGCONT the recorded pids only |
| Docker rule (default off) | if Docker Desktop is running and docker ps -q is empty, journal its tree, ask docker ps -q once more right before the SIGSTOP and freeze it only on a second clean empty answer; busy, a failed probe, a timeout, a lid open or a session end at either point leaves it running |
resume |
| Mute (optional, default on) | journal the default output's UID, name, volume and mute state as its own entry, unless that device already has one, then mute that device; another device's entry never stops it | restore each entry on its own device, never on another output, and clear only that entry; a device the user unmuted meanwhile is left as it is; a device that is not connected keeps its entry (see below) |
| Low Power Mode | on (optional, default on) | off unless a battery or thermal floor still wants it |
| Countdown redraw | stop timer | restart timer |
The display and keyboard rows go through private frameworks with no ABI
contract: the DisplayServices C functions and CoreBrightness's
KeyboardBrightnessClient. Two guards run once per launch, before the
first call. The DisplayServices functions are called only on a macOS major
version they were measured on (26, DisplayPower.measuredDisplayServicesMajors);
a C symbol carries no type information, so on any other major the display
is left alone until someone measures again. The KeyboardBrightnessClient
methods are read through the Objective-C runtime and compared with the type
encodings measured on macOS 26 (DisplayPower.measuredKeyboardClientEncodings,
stack offsets removed); a missing required method or a changed encoding
refuses the keyboard backlight before anything is instantiated. A refused
device is skipped at lid close with a log line, nothing is journaled for
it, and Settings shows the reason under the darken toggle. With nothing
journaled for either device the close does not request display sleep,
since lid open and reconcile wake the display only for a journaled
brightness, as section 4 describes.
A brightness journaled before an update that the guard now refuses is not
written on open or reconcile, and it is not dropped either: an entry is
cleared only after its undo. It stays in the journal with
displayRestoreRefused or keyboardRestoreRefused set. One error names
each such device with its saved level, says it could not be restored on
this macOS build, and says to set the level with the brightness keys or
Control Center; Settings also shows each saved level under the darken
toggle. That error goes into one report with every other failure of the
same undo, such as a failed resume, audio restore or write on the other
device, since it comes up at every lid open. A flagged entry is not dirty
for backstop.sh or uninstall.sh, nor for the app while the guard refuses
its device. Nothing on that build can restore it, and counting it would
post "Restore incomplete" at every end and launch, fail the backstop every
minute, and stop uninstall for good. It also does not hold back a new
session or Quit. The app tries again at every lid open during a session,
at every end, and at every launch, with or without a session. On a build
or macOS where the guard allows the call, it reads the device first. Only
a reading taken with the lid known to be open, and that macOS is not
holding down, counts. A closed lid leaves the device at 0 or turned off,
and a write would light what the close keeps dark, so an end or a launch
under a closed lid reads nothing and leaves the entry waiting. A display
asleep reads its idle-dim value, and a keyboard backlight suppressed after
the wake, or idle-dimmed, reads 0 at any level. Such a reading decides
nothing, and neither does a read that fails or a keyboard that reads as
absent. The entry then stays as it is. The app reads it again every 3 s,
20 times, and then every minute for as long as it waits and the app runs,
since outside a session nothing else acts on a lid open. While the lid is
not known to be open it reads nothing, and the 3 s reads start again once
it is. A busy recovery lock skips one read, not the rest. An end does not
count a waiting entry as not restored, since nothing failed. The close
left the device at 0, so a reading above 0 is a level set since, as the
error asked: the darkening is already undone, and the entry is cleared
without a write rather than overwrite that level. A display reading above
0 taken while Insomnia's own Low Power Mode is on is the mode's rescaled
value and not that level, and once the mode is off the panel comes back
over a time nobody has measured. So no display reading above 0 taken
under the mode, or after it in the same boot of the Mac, decides the
entry: it would become the sample a later close journals. A relaunch is
no sign that the panel is back, so the journal records the entry as one
the mode was on over (keptDisplayUnderLowPower, with the boot session),
and a relaunch reads it with the same doubt. The entry waits for a launch
after a restart; until then it stays flagged, the panel is read again
every minute, and a lid close leaves it lit and only asks it to sleep
(below). That a restart ends the mode's rescale is assumed, not measured.
The record is about that entry alone: settled, replaced or unflagged, it
is dropped, one with no boot session holds until the next restart, and a
journal written before the record existed carries no doubt. While the
journal still claims the mode (lowPowerSetByUs), the record keeps the
boot it was written in, so a launch after a restart can tell that the
claim is from before it. Such a claim says nothing about the mode in this
boot, and neither does a read of the mode. Before its lowpowermode 0 the
app reads the mode (pmset -g custom, the battery setting the claim is
about) for the log only. On, the switch-off ends the mode in this boot.
Off, the user or another tool may have switched it off a moment before,
with the panel still on its way back. Either way, and when the mode cannot
be read, the switch-off counts as the mode's end in this boot: the record
is written for this boot and the entry waits for the next restart, as
above. That holds for every route that switches such a claim off: an end,
a launch, the menu or a floor, and the check after a power command. It
also holds when state.json refuses the clear, in this process and in a
relaunch in the same boot. The cost falls on a mode that went off long
before the launch, which no reading tells apart: until a launch after the
next restart the entry stays in state.json, the display is not sampled,
and a close leaves it lit and only asks it to sleep. The level the user
set is never written over. A claim with no record, a
record with no boot or an empty one, or a launch that cannot read its own
boot is taken as this boot's, so the entry waits, at the cost of one more
restart. backstop.sh, before its own lowpowermode 0, gives a record of
another boot, or with none, its own boot (empty if it cannot read it),
after a restart or not, and publishes that journal on its own first: the
mode may have been on in that boot until then, before Insomnia launched in
it. A launch in that boot then reads the entry with the same doubt and
waits for the next restart, even when the mode was already off before the
agent's switch-off. The record has that boot before the mode goes off, so
a command that fails, or a journal of the undo that cannot be published
after it, leaves the claim next to a record of this boot, which a launch
reads as its own mode on in this boot, with the same doubt. If the journal
with the boot cannot be published, the agent leaves the mode on and keeps
its entry for the retry: switched off next to a record of an earlier boot,
the panel on its way back could be taken for the user's level. A journal
with no record has nothing to give a boot, so one written before the
record existed still carries no doubt. A display still at 0 under or after the
mode gets the saved value, written once more after the mode as for any
restore under it, unless a reading of that entry above 0, with the lid
known open and the panel awake, showed the darkening undone. That reading
is journaled (keptDisplayReadLit), so it holds through relaunches and
restarts, which do not darken the panel again. A 0 read after it may be a
level the user set, or one macOS still holds the panel at: auto-brightness
can pull the panel down to 0 under a closing lid, and the panel can still
read that 0 once the lid is open again, for a time nobody has measured. No
reading of 0 tells the two apart, however late it comes, so the saved
value is not written and the entry is not cleared. It stays flagged, the
display sample stays held, and the app reads the panel again every 3 s,
20 times, then every minute, through relaunches and restarts, until it
reads above 0. A 0 the user set stays as set until then. A reading above
0 with no doubt above left is the level set since, and the entry is
cleared without a write. A lid close with a sample of 0, or 0 as the value
owed after the mode, journals that 0 in place of the kept value. A current
read of 0 at the close is no such level, since it may be the closing
lid's. The close leaves the entry flagged and the panel as it is, and only
asks it to sleep. A reading at a lid close, under the closing lid or of a panel
asleep, shows no such thing, and neither does a reading of an earlier
entry: the record goes with its entry, as the record of the mode does. If
state.json refuses the record, this process holds it and the next write
records it. An end does not let Quit go before then: "Restore incomplete"
says Insomnia retries in 30 s and not to quit until state.json can be
written, and Quit waits, as for a failed restore whose flag is owed. A
crash, a forced quit or a lost disk before the record lands loses it, and
the next launch writes the saved value over a 0, as before any reading.
If state.json cannot
take that clear, the entry still counts as done in this process. The clear
is owed: it goes into the journal ahead of any later write, and at the
start of every lid close, lid open, end and launch, so none of them works
from the old entry. A lid close then journals the level the device reads,
not the old saved one. The app also retries the clear at the same pace,
and reads and writes nothing for it, so a level the user lowers to 0
meanwhile stays at 0. Quit waits for that clear: a launch after the quit
would read the entry again, and would write the saved value over a 0 the
user set since. "Restore incomplete" says Insomnia retries in 30 s and not
to quit until state.json can be written, and Start is refused until the
end goes through. A crash, a forced quit or a lost disk before the clear
lands leaves the entry to the next launch, which reads the device again.
A device still at 0 there gets the saved value and both keys are cleared. A failed write there clears the
flag, and the entry is retried like any failed restore. If the flag cannot
be cleared either, the disk still shows a flagged entry, which backstop.sh
and uninstall.sh pass over, so the app keeps the end itself. "Restore
incomplete" says Insomnia retries in 30 s, the app retries the write and
the owed flag together, and Quit waits until one of them lands. A write
that lands while state.json still refuses the clear settles the entry in
this process, with only the clear owed, and Quit waits for that clear as
above. A launch or
re-read with no session whose write fails is ended as for a dirty journal,
so the agent is armed for it, or the app keeps it while the flag is still
owed. A write whose clear is owed is done all the same: the second write
2 s later still goes out, and a display written under Insomnia's Low Power
Mode still owes its write after the mode, as the clear would have
journaled. A lid close that can read the device clears the flag too: it
keeps the earlier saved value while the device reads 0, and saves the new
level when it reads above 0. For the display that level must be trusted:
the last sample, the value owed after the mode, or a current read taken
with the panel awake and Insomnia's Low Power Mode not on at any point in
the run, nor over the entry since the Mac last started. Without one, the close leaves the entry flagged and the panel as
it is. A rescaled, dimmed or asleep read is not the user's level, and the
saved value may not be either, since the user may have set one by hand.
A panel darkened to 0 would read at the open as the darkening never
undone and get the saved value, so the close only asks the display to
sleep, which macOS ignores while any process holds a display assertion;
the panel then stays lit under the closed lid until the open, which reads
the entry again as above. A close whose journal write fails
darkens nothing and asks the display to sleep only for an entry this
process has not settled. Uninstall goes ahead past a flagged entry, prints
the saved level, and keeps state.json, even with --purge, so a later
install that can make the call restores it.
The guards narrow the risk of calling a private function whose shape
changed; they do not replace the real-Mac checks in docs/releasing.md.
An output device that is not connected when its entry is restored keeps the
entry, and the restore of everything else goes on. Each restore decides
again whether a device is connected: only a device CoreAudio reports as not
connected on that try counts as away. An away device does not hold up the
end of a session: End and Quit restore sleep and the rest, the end
notification names each device that is still muted, and the menu shows a
line for each with a "Stop waiting for " item, which drops that
entry and leaves the device as it is. The item drops nothing if, when it
runs, the device reads as connected or the entry is a later lid close's
save rather than the one the menu showed. Each save has an ID of its own
(saveID, drawn by the lid close that writes the entry), and the item
compares it with the journal on disk under the lock, so a later save with
the same values, written by this or another copy of the app, is not
dropped. An entry written before entries had an ID has none and is
compared by its values. While Insomnia runs, a device that
connects again gets its volume back at once (a CoreAudio device-list
listener), or at the next lid open if a session is running with the lid
closed. Each device change reads the journal on disk under the lock, so a
copy of the app with nothing saved in memory also restores a save another
copy wrote. Before the launch reconcile has taken a session over, a session.json
that has not expired, or cannot be read, counts as a running session for
this. A later launch restores every connected device at reconcile.
A restore that fails on a connected device, or a cleared entry that cannot
be written, is reported and the entry stays. It makes the end incomplete,
as any failed restore does. The recovery agent keeps these entries but
cannot restore CoreAudio settings, so the incomplete-restore notification
says Insomnia tries again itself. That retry runs in process after 30 s,
reads the journal again and checks the lid again. A device change that
could not run at all (the recovery lock was busy, a sudo pmset left
running still held it, or state.json did not decode) is retried the same
way. The retry stops after 10 tries in a row; the next device change, lid
open, end or launch tries again. The menu line that a refused device change
or a failed restore put up goes once a later restore leaves nothing to
retry, unless a newer failure has taken its place.
A session that starts, or that reconcile resumes at launch, while the lid reads closed starts with the countdown redraw stopped: the lid observer reports changes only, so no close event arrives for it. The next lid open restarts the redraw. Freeze scope rules:
- Two scopes. The explicit freeze list: apps the user picks by bundle id from
a list of currently running apps; always frozen. The automatic scope
(
freezeAllApps, default off, also for a config.json without the key): every running app with a regular activation policy (a Dock app) and a bundle id, so that only agents keep running while the lid is closed. Menu-bar (accessory) and background apps are never picked up automatically; they can be put on the explicit list by hand. With the toggle off the explicit list is the whole scope. - Hard denylist that can never be frozen, from either scope:
com.apple.*, Insomnia itself, Docker Desktop (handled by the Docker rule), and any bundle id in the agent list (below). - Built-in protected set (
FreezePlanner.builtInProtected, checked throughisBuiltInProtected): editors and agent hosts (VS Code and Insiders, VSCodium, Cursor, Windsurf, Zed, Antigravity and Antigravity IDE, Android Studio, Sublime Text, Nova, Claude, ChatGPT/Codex, Conductor, T3 Code), every JetBrains IDE by thecom.jetbrains.prefix (builtInProtectedPrefixes), terminals (Warp, Ghostty, iTerm, Alacritty, kitty, WezTerm, Tabby, Hyper), browsers (Arc, Chrome, Chromium, Edge, Brave, Vivaldi, Opera, Firefox with its Developer and Nightly editions, Zen), Tailscale, LM Studio, Ollama, Docker Desktop's Electron front end, 1Password, Bitwarden, Postgres.app and OrbStack, and meeting, recording and dictation apps (meetingApps): Zoom, Microsoft Teams (new and classic), Webex, the older Webex Meetings app with its meeting window and plugin agent, Wispr Flow, Granola, Otter, OBS and Loom. Their helper apps are matched by prefix: each of those ids followed by a dot, plusus.zoom.andcom.cisco.webex.(meetingAppPrefixes). Helpers an app starts are its child processes and are left out with it. FaceTime iscom.apple.FaceTime, already on the hard denylist. Every id is verified against an installed copy, the Homebrew cask metadata or the page named in the comment next to it. The automatic scope leaves them alone even when they are not on the agent list. Code level and not persisted: an existing config.json already carries its own agent list, so new agent-list defaults never reach it. An explicit freeze-list entry overrides this set; the hard denylist does not. - Order: the explicit list first, in its own order, then the automatic candidates by app name, de-duplicated. One info log line names the automatic candidates on each close.
- Each app's pids are journaled before the SIGSTOP, without identity; if that write fails the app is left running and the status menu shows the failure. Recovery never signals an entry without identity. After the SIGSTOP one write gives the pids the kernel stopped their identity (start time to the microsecond, boot session) and drops the pids it would not stop, so a process somebody else had stopped is never claimed. If that write fails, the app sends SIGCONT to each pid it just stopped whose identity still matches, stopped yet or not (SIGCONT also cancels a stop that is still pending), then removes that app's entries and any Docker flag the freeze set, keeping only pids it could not resume. The status menu shows the failure and counts only those pids as frozen, even while the disk refuses the removal; the next journal write that succeeds carries it. If the app dies before the confirming write, the stopped pids stay journaled without identity and are reported for a person to check.
- Only pids Insomnia stopped are resumed. An app launched while the lid is closed is left alone.
- Electron apps are stopped as a whole process tree (main + helpers), found via the responsible-pid relationship, so no helper keeps spinning.
Display and keyboard backlight are handled by Insomnia, not by the hardware.
The September 16, 2026 audit (docs/release-validation.md) found that the
sleep guard (pmset -a disablesleep 1) removes the only path by which macOS
turns the built-in panel and keyboard backlight off on clamshell close: that
happens on the system-sleep path, which the guard disables. The earlier claim
here that both are "already off by hardware" was wrong under the guard, so
the panel and keys stayed lit for the whole closed period. A display sleep
request (IORequestIdle on IODisplayWrangler, what pmset displaysleepnow
does) is ignored while any process holds a display assertion, and agents
routinely do, so brightness 0 is the primary mechanism and the sleep request
is best effort. It is made only when a display or keyboard brightness is
journaled, because the wake that undoes it on open, or on reconcile after
a relaunch, runs only for such an entry. Display brightness 0 does not
switch the keyboard backlight off; it is set separately. Both values are journaled before they are changed
and restored on open, session end, Quit, or reconcile with the lid open; the
backstop keeps the entries and only the app restores them (private
frameworks). An end that could not restore one says so in its
incomplete-restore notification, without promising the recovery agent's
retry: a later session's end or the next launch tries again. What is journaled is the user's value, not whatever the device
reads at that instant. A reading is trusted only when the last keyboard,
mouse or trackpad input was under 30 s ago (CGEventSource; the idle dim
never starts sooner) and the panel is awake (CGDisplayIsAsleep, else it
reads the idle-dim value) or the keyboard backlight is neither suppressed by
display sleep (it reads 0) nor idle-dimmed, and the app keeps the last trusted
reading of each, sampled every 30 s with the lid open, at start, 3 s after
each lid open, and just before Insomnia switches Low Power Mode on itself.
The display at the close event is never the user's value when it can be
avoided: the lid coming down covers the ambient light sensor and
auto-brightness has already pulled the panel down by the time the close is
reported (measured 0.75 to 0.335 with the user at the keyboard, so the idle
rule alone does not catch it), and Low Power Mode rescales it (0.75 reads
0.5). So the display journals the last open-lid sample; without one, the
value a lid open restored under Insomnia's own Low Power Mode if that is
still journaled as owed and the mode still ours (the app relaunched under
the mode, so the new sampler has nothing and is held); only without either
the current read,
dim or not, since a dim panel on open beats a black one.
While Low Power Mode is on because Insomnia switched it on (the journal's
lowPowerSetByUs), the display sample is held: the panel reads the mode's
value, and the sample taken just before the mode went on is the one to
restore. The hold follows the journal on disk: after a lowpowermode 0
that exited 0 with the ownership clear refused, the panel may still be on
its way back from the mode, so the display sample, by then the level
written after the mode, stays held until the clear lands, while the
keyboard is still sampled. Each device's sample is also held while the journal has a saved
brightness for it, since the device then reads the 0 a close left or a
level not yet decided. Each level the app writes from the journal, or
finds set since in place of a kept value, becomes that device's sample,
so a close soon after a restore that came late, from the re-read of a
kept value or after the mode, journals the restored level and not a 0
sampled before it. The keyboard journals the current
read if trusted now, else its last sample; when nothing trustworthy is
known for it, it is left to macOS entirely (no journal entry, no write),
because restoring a suppressed 0 would leave the backlight off. On open the
restore is written, the entries cleared, and the same values written once
more 2 s later, because powerd re-applies its own remembered brightness
asynchronously after the wake and can override the first write; that second
write is skipped if the lid closed again in the meantime (the close journals
fresh values first) and superseded by any newer restore. A display restore
written while Insomnia's own Low Power Mode was on is journaled as
displayRestoredUnderLowPower and written once more, then re-asserted the
same way, right after Insomnia switches the mode off (lid open, charger,
thermal recovery or session end), because the mode's end rescales the panel
and can leave it elsewhere than the value written under it. That second
write is owed only while the panel still reads what was written (within
0.05, auto-brightness drift): a panel the user has moved with the keys
since the open is theirs and is left alone. It is dropped, too, if the lid
closed again meanwhile (the next open restores with the mode already off),
and if the mode turns out to have been cleared by someone else (the
backstop after a kill: it keeps the entry but never writes brightness);
taking the mode over discards any entry left from an earlier interval in
the same journal write. A Low Power Mode interval with no lid restore under
it writes nothing. If state.json refuses to clear the mode's ownership
after lowpowermode 0 exits 0, the mode is off all the same: the clear is
owed, as for a kept brightness, the write after the mode goes at once, and
a later switch-off from the ownership still on disk compares nothing. If
state.json refuses to clear the owed write itself, written or dropped,
that clear is owed too, so no later journal write brings the value back. The
sample the display journals dates from the last 30 s window in which the
user was active with the lid open and the panel awake, and is held from
before Insomnia's own Low Power Mode: a brightness change made within 30 s
of closing the lid, or under that mode, is not seen. If Insomnia is not running when the lid
opens, the brightness-up key restores the panel. Bluetooth is still left alone (needed for Instant
Hotspot, and negligible).
Low Power Mode on lid close is decided by the same rule as the battery and thermal floors (section 6), so the three causes never fight over the mode: it stays on while any of them holds and is switched off when none does. It applies whether or not the charger is connected, because with sleep disabled a closed Mac otherwise runs at full speed and heats up. A lid-caused change is logged but not announced: switching the mode off is announced only when the cause that last held while it was on was the battery or thermal floor, not the lid.
- Agent list (bundle ids, default: T3 Code, Conductor, Terminal, iTerm, Ghostty, Warp, Chrome, Chromium, Arc, Docker Desktop, VS Code, Cursor, Zed, Antigravity, Claude, ChatGPT/Codex, Tailscale, LM Studio, Ollama). Editable.
- Built-in protection (section 4): the same editors, agent hosts, terminals, browsers, VPN and local model runtimes, plus password managers, local databases and OrbStack, are protected from the automatic lid-close scope even on an install whose config.json predates these defaults and never lists them. Only the agent list can also turn App Nap off; only an explicit freeze-list entry overrides the built-in protection.
- Turning App Nap off is opt-in (
disableAppNapForAgents, default off). With it off Insomnia never writes another app's preferences. With it on, session start reads each listed app'sNSAppSleepDisabled, journals the previous value (absent, true or false) inappNapOverrides, and only then writesYES. A journal write failure means no preference write. An app whose key is alreadyYESis skipped: there is nothing to put back. So is an entrydefaultswould not read as that app's domain (a leading-, a path,NSGlobalDomain, anything but ASCII letters, digits,.,-and_), since the backstop could not put it back; it is logged. Session end, reconcile, the backstop and uninstall write the recorded value back (defaults deletewhen it was absent) and clear the entry only after that write succeeded. Settings shows the toggle, the list of apps it affects, and what it changes. - Values written by builds before this were never recorded and are not
guessed at: uninstall reads the key for every app on the shipped agent
list and on config.json's (an app taken off the list may still carry one),
lists each whose key is
YESwith no journal entry, prints the shell-quoteddefaults deletecommand for it, and continues. The summary says how many apps were checked; an app whose key cannot be read is reported, not counted. Each read has a 30 s limit, like every other call uninstall makes under the recovery lock (pgrep,launchctl,codesign); a read that does not answer ends the check with the command to run by hand, and uninstall goes on. A call past its limit gets SIGTERM, then SIGKILL one to two seconds later. Each call keeps the lock until it has exited or been stopped, and a supervising process enforces the limit even if uninstall is killed while it waits, so itslaunchctl bootoutis not still running when the app takes the lock and loads its agent. - Browser throttling: Chromium browsers throttle windows macOS reports as
occluded, which is every window once the lid is closed with no external
display. Timers drop to 1 Hz, animation frames stop, pages report hidden.
This can break computer-use and browser-use agents.
- Insomnia inspects running Chromium processes for
--disable-backgrounding-occluded-windowsand--disable-renderer-backgrounding. - If a browser is running without them, the menu shows a warning and a "Relaunch unthrottled" item that quits and relaunches it with both flags and the same profile.
- The item asks for confirmation first (the browser is quit; its windows
return only through its own session restore). The item carries the
browser's bundle id and name from when the menu was built, and
confirming hands those on, so a browser scan that replaces the list
while the alert is up cannot change or drop the browser; one that has
quit by then is reported as not running. The profile arguments are
read before the quit, and unreadable arguments (including empty
psoutput) stop the relaunch before anything is quit. So does a main process that exits during the read, checked byNSRunningApplicationand by the kernel's start time for the pid, sincepsreads by pid and the pid may have gone to another process. A start time that cannot be read counts the same way: nothing confirms the pid is still the browser, so the arguments are not read and nothing is quit. The quit goes to theNSRunningApplicationobjects found before the read, never to a fresh lookup of their pids. After the quit request Insomnia waits up to 10 s, then reads the running list again: any instance still there means nothing is launched, and the notification says the browser may still quit later and then has to be opened by hand. Afteropenreturns 0 the running list is polled for up to 5 s; a browser not running by then is reported too. A session that ends during that wait cancels it at once and nothing is reported, since the user ended the session. The 10 s quit wait does not stop on a cancel, so a relaunch whose session ended during it reports nothing either when the wait is over. Only the newest relaunch of a browser reports, so one that a newer relaunch of the same browser overtook reports nothing either. Every other outcome short of a relaunch is a "Browser not relaunched" notification naming the browser, and the same text stays in the menu as a warning line, one per browser, until that browser's next relaunch or the next session start, because notifications can be off for Insomnia. Insomnia is the notification center's delegate and asks for banners while it is frontmost, as it is right after the confirmation; without that, macOS drops a notification from the frontmost app. The process side (BrowserProcessControlling) is injected so the tests quit nothing. - Headless Playwright is unaffected and needs nothing.
- Must be verified on the real machine with the lid shut (see test plan). If macOS 26 does not mark windows occluded in this state, the feature is reduced to the App Nap default and the warning is removed.
- Insomnia inspects running Chromium processes for
Event sources: IOPSNotificationCreateRunLoopSource (fires on every battery
percentage change) and ProcessInfo.thermalStateDidChangeNotification. While
the battery is unreadable the power source list is re-read every 30 s, since
IOKit sends no event for a read that keeps failing.
| condition | action | undo |
|---|---|---|
battery below lowPowerFloor (default 40%) |
pmset -b lowpowermode 1 |
charger connected, or session end |
battery below endFloor (default 10%; 0 turns the end off) |
end session, notify | — |
battery present but unreadable on two consecutive reads, on battery, endFloor above 0 |
end session, notify | — |
thermal state serious |
lowpowermode 1 |
thermal back to nominal/fair, or session end |
thermal state critical |
end session, notify | — |
lid closed (if lowPowerOnLidClose, charging or not) |
lowpowermode 1, no notification |
lid opened, or session end |
PowerMonitor tells a machine with no internal battery (desktop: no floor
applies) from one whose battery is present but not reported, by checking for
the AppleSmartBattery service when the power source list has no battery
entry or cannot be read at all. On that laptop, charger or battery comes from
the driver's ExternalConnected property in the I/O Registry rather than the
list that failed; when that cannot be read either, the laptop is taken to be
on battery, so the session ends rather than holding sleep with no floor on a
battery that may be draining. One missed read is tolerated as transient; the
second ends the session, because the end floor cannot be applied to a level
nobody can read. Only the IOKit event and the 30 s re-read count misses; the
menu's own refresh reads the level but leaves the count where it is, so a
menu opened during a transient miss is not the second one, and the count
never moves without the floor rules running. An unreadable level never counts
as below a floor, so it does not enable Low Power Mode by itself.
A non-zero endFloor stays below lowPowerFloor, which gives Low Power Mode
a chance to come on before the session ends. It is not a guarantee: a reading
already below both floors, or one that crosses both between evaluations, ends
the session without it. Settings enforces the order in 5% steps by moving the
other floor when the two would cross (endFloor at most 95). A config.json
that violates it is corrected at load by raising lowPowerFloor to endFloor
- 5 (capped at 100), logged, and written back.
Insomnia does not enable Low Power Mode merely because a session starts; the causes are the battery floor, a serious thermal state, and (by default) a closed lid. One evaluation owns the mode: it is switched off only when no cause holds. Battery and thermal rules run only while the app is alive; they are not provided by the standalone backstop. Performance effects depend on workload.
NWPathMonitoron the Wi-Fi interface. Event-driven.- Path unsatisfied for more than 5 s: use CoreWLAN to run an SSID-filtered
scan, then join with
associate(to:password:). The password is read from the login Keychain (generic-password serviceinsomnia-hotspot, account = SSID) and is never placed in process arguments. Retry with backoff (5 s, 10 s, 20 s, 30 s, then every 30 s) until the path is satisfied or the session ends. - The Keychain item is created with an access list naming only the saving
build (
SecAccessCreatewith the running code as the sole trusted application; under ad-hoc signing that is the build's cdhash). Reads run with the process-wide Keychain prompt switch off (SecKeychainSetUserInteractionAllowed, put back after each call), since the per-query no-UI keys only govern the data protection keychain. An item the build may not read fails witherrSecAuthFailed(another build's item, or a locked keychain; the file-based keychain cannot tell them apart), and a missing item witherrSecItemNotFound. Either skips the join and sets aHotspotPasswordReport, theHotspotPasswordProblemand the SSID it was read for: a warning line in the right-click menu and a notice under the password field in Settings, both only while that SSID is the one configured, and one notification per outage and hotspot (re-armed on recovery, on stop and when the password is saved). Settings checks the notice again whenever the configured SSID changes. A save in Settings writes to the keychain that holds the item reads find, which need not be the default keychain (a new item goes to the default keychain). A locked keychain hides this build's items as well as another build's, so a save that finds the item unreadable first checks the keychain's lock state (SecKeychainGetStatus) and, if it is locked, unlocks it (the prompt) and starts over. An item the build can read already names it, so only the value changes, in place (SecItemUpdate). An item it still cannot read is another build's and needs a new access list, and the file-based keychain changes that only by replacing the item (an in-place update ofkSecAttrAccessdid not return when tried on a throwaway keychain): the new password is put beside the old item, in the same keychain, under serviceinsomnia-hotspot.replacing; the old item is deleted; the new one is renamed toinsomnia-hotspot. Reads use onlyinsomnia-hotspotand never fall back to a.replacingitem: which save left it, and whether that save finished, is not known, and one in another keychain on the search list can hold an older password. Until the delete, the password reads as unreadable, as before the save; after the rename, as the new one. A save that stops in between (a failed rename, a crash) leaves it reading as missing, and the user enters it again. A.replacingitem the build can read, left by such a save, has its value changed in place by the next save; one it cannot read with the keychain unlocked is another build's and is deleted, then added again. A refused delete of the old item removes the.replacingitem and keeps the old one. Clearing the password deletes both. Deleting another build's item, and unlocking the keychain for a save, need the prompt, which is allowed only there.kSecAttrAccessibleis not set: the file-based keychain drops it, and the data protection keychain needs an access-group entitlement. - Every keychain call the app makes, the failover's reads and the saves
and clears in Settings, runs on one serial dispatch queue
(
KeychainQueue), never on the main actor. A save can wait on a keychain prompt for as long as the user leaves it open, and the battery floor, the deadline timer and End keep running meanwhile; one queue also keeps two calls from setting the process-wide prompt switch at once. The Save button reads "Saving…" until the keychain answers, then "Saved" only while the SSID and password fields hold what the save stored: the save uses the SSID that was in the field when it began, so an SSID typed during the wait has no password yet. A failed save's notice stays under the field even when the failover's report changed during the wait; the recheck that change started reads the keychain behind the save, and its answer is dropped. So is the answer of a load still running when a save or clear begins, so it cannot refill a field the user just cleared. A load fills the field only if the field was empty when it began and nobody has edited it since, not even by typing and deleting it again. A recheck that begins meanwhile (the report changed, or the SSID was edited) sets the notice instead of the load, but does not stop the fill. - Work that waits on
KeychainQueuechecks again, once the wait is over, everything it acts on, and drops its answer if any of it changed. A load or recheck in Settings whose SSID was edited meanwhile is dropped, since its answer is about the old SSID's item, and the SSID configured now is read instead, so the field is not left empty with no notice. That read is a peek: the SSID a later save moves the password from stays the one the window loaded, as after any SSID edit. A save writes the item for the SSID configured when it began and removes the item of the SSID the window loaded, if that SSID was edited since, and returns both. It clears the failover's report and re-arms its notification unless the report is about the SSID configured when it answers and the save touched neither of that SSID's items. A save that stored for an SSID edited away meanwhile then checks the notice for the SSID configured now. A failover join whose read waited behind a save does nothing if the session has ended, Wi-Fi has come back, or the configured SSID has changed: no join and no warning, so the notification stays armed. After a recovery or stop it schedules no retry either; after an SSID change the retry stays, and the next tick reads the SSID configured then. Inside a save, the unlock prompt is followed by a fresh read of the item and its keychain. - macOS 26 requires Location Services permission before CoreWLAN exposes SSIDs
or returns results for an SSID-filtered scan. Insomnia requests when-in-use
access when the hotspot is saved or a configured session starts, never at
launch. Mac apps have no when-in-use state: a grant settles on
authorizedAlways, and System Settings records it as Location Services access for Insomnia (the Settings window says so next to the Location row). Insomnia never starts location updates. The real hotspot join still must be run on the Mac in the manual test plan below. - Log lines naming the SSID, a tmux target or a process reach the unified
log as private data (
Log.swift), solog showprints<private>for the body;insomnia.logkeeps the text. - Each outage is logged with start, end, and gap length to
~/Library/Logs/Insomnia/handoffs.log. The menu shows the last gap. Likeinsomnia.log, the file is owner-only (0600) and is renamed tohandoffs.log.1once it passes 1 MiB (OwnerOnly.swift). A log the user replaced with a symlink is never rotated, so the cap does not hold for it: the file it points to is the user's to manage. - Path satisfied again after a gap longer than
nudgeThreshold(default 90 s):- For every configured tmux target (
session:window.pane), resolve the concrete pane, read its state and then its mark, the pane-scoped user option@insomnia-nudge(show-options -qpv -t %N, without-A, so a session or window option never counts). Only a pane markedonby the user (tmux set-option -p -t <target> @insomnia-nudge on) getstmux send-keys -t %N continue, followed byEnteronly whentmuxNudgePressesEnteris on (default off). An unmarked pane is skipped and logged. - Post a notification: "Network was down 2m 10s. Nudged 2 tmux panes. Check GUI agents."
- For every configured tmux target (
- Recommended one-time setting, documented in docs/extras.md: System Settings > Wi-Fi > "Ask to join hotspots" = Automatically.
Invariants:
- Sleep is never disabled unless a session file with a future
endsAtexists. - Every change Insomnia makes is in RuntimeState before it is made, and is undone from RuntimeState, never from memory.
Reconcile runs at every Insomnia launch:
- Session file missing or expired → restore journaled changes: sleep,
verified owned processes, Low Power Mode if we set it, saved audio, and
recorded App Nap values.
Unverified entries and failed restoration remain unresolved, not successful.
A session file that does not decode counts as expired: it is renamed under
the lock to
session.json.unreadable-<UTC stamp>(never deleted, never overwriting an earlier copy), the user is told where, and the journal is restored as with no session.backstop.shdoes the same once the journal is clean. A session file that decodes as JSON but lacks a key or type theSessiondecoder needs (startedAt,endsAt,extensions) is not a session either;backstop.shchecks the same keys and types. The app and both scripts read a date in one form only:2027-01-15T08:00:00Z, as Store writes it, or the same with an offset such as+02:00in place ofZ, in whole seconds, naming a date and time that exist, years 1970 to 9999 (Store.parseDate,epoch_ofin the scripts). A session file that exists but cannot be read at all, or is not a regular file (never opened: a FIFO would block under the lock), has no end time that can be enforced, so it also counts as expired and the journal is restored. It may have been a valid session, so it is never opened or removed: it is renamed aside the same way (the app at once,backstop.shonce the journal is clean), which keeps it as evidence and keeps a later launch from resuming a session that was treated as ended. The app notifies with the new path. If either rename fails the file stays and a start is refused while it is there. Every end then restores the journal and tries the rename again; while it fails the end is not finished, so quit is refused and the end is retried. The launch that kept the file runs that end itself when the journal holds any entry, saved output volumes alone included. A file that could not be read would be resumed if it became readable in place, and every later launch and the agent read either kind again. The messages say to remove it or move it out of the folder.backstop.shtries the rename again on every run. An unreadable journal still refuses every transaction and leaves both files in place. - Session valid → establish the independent recovery agent before reapplying the sleep guard, then resume observers. If the lid is open, restore recorded lid-close actions. Arming or restoration errors must remain visible.
pmset -greportsSleepDisabled 1with no session and no journal entry → leave it. Step 1 has already undone a disable Insomnia journaled, so this one was set by something else (a hand-runpmset, another tool) and is not Insomnia's to undo. Log it, show it on the menu's warning line, and notify once per launch withsudo pmset -a disablesleep 0. A bit still journaled as ours after a failed restore is retried from the journal, not from this check. Nothing clearsSleepDisabledwithout a journal entry, in the app or in the agent.
A reconcile refused for a busy lock or an unreadable journal changes
nothing and runs again after the retry delay until it goes through, unless
a start has made a session active or an end has been requested since. The
lock can stay busy for as long as a sudo pmset an earlier run left
running, and without the retry a session still live on disk would hold
sleep with no battery floor until its deadline. If unfinished-command.json
was on disk, that command has exited by the time the reconcile holds the
lock, so a session it resumes is checked against Low Power Mode as after a
command the app itself left running (below). The check stays owed when a
later attempt is refused for an unreadable journal, although that refusal
removes the record. A start that makes a session active before the retry
runs the check for that session instead. An end restores the mode from the
journal and owes no check.
Backstop, independent of the app:
- The agent reads the saved deadline; recurring recovery checks avoid replacing the loaded job for every extension and allow retries after a failure.
- The agent runs only the
backstop.shsealed in the signed bundle. Its command line verifies the bundle against the code requirement pinned in the plist (codesign --verify --strict -R=...; for an ad-hoc build, that build's cdhash) and execs the script when that passes; otherwise it logs one line and exits without running anything. No executable lives in a writable directory. The plist is a per-user file like any LaunchAgent; at the next arm the app rewrites a plist that does not match, and reloads a loaded job whose command line or run interval (theargumentsandrun intervalthatlaunchctl printlists) differs from the plist's. - What the app pins is the requirement of the code it is running
(SecCodeCopySelf), read after SecCodeCheckValidity confirmed the bundle on
disk is that code, and the bundle must pass the agent's own check against
it at every arm. Otherwise arm() fails with the reason: a loaded agent
whose bundle no longer verifies is never reported as armed, and a bundle
re-signed under the running app is never re-pinned. A
swift runbuild outside any bundle pins the installed bundle from disk. - install.sh replaces the bundle and the agent in one locked step: the new
bundle is staged next to the app and swapped in only after
launchctl printconfirms the previous job is unloaded, then the new job is loaded. So any job loaded after the swap is this run's and pins the new bundle. When the new job cannot be loaded or its plist cannot be published, install.sh unloads any job that may be loaded, confirms that with print, and puts the previous bundle back; if the unload is not confirmed, the new bundle stays, because that job pins it. Every bundle rename is checked (mv, refused when the destination exists): when one of the swap or its undo fails, the previous bundle goes back and its job is loaded again as after a failed load, and when the previous bundle cannot go back, no bundle is deleted, no job is loaded against an empty app path, and the message prints themvandlaunchctl bootstrapthat restore the pair. The same holds for the renames of the repair below. A rerun after an interrupted or failed swap keeps the bundle the plist on disk pins. It runs its forced recovery first, with the loaded job and the bundles as the earlier run left them, and stops there if recovery fails. Only then does it unload and confirm any loaded job, move a bundle and load the plist on disk again; it stops when print does not confirm the unload or that reload. A loaded job is never left pinning a bundle that was moved away, and no step after a failed bootstrap counts on a loaded job. Before recovery the run only puts a set-aside bundle back when nothing is at the app's path, and removes staging directories whose owning install is gone (matched by the exact name install.sh gives them). Right after taking the lock it checks the sudoers rule again withsudo -n -lfor each of the four commands and stops if it no longer holds: an uninstall.sh that took the lock first removes the rule and leaves no journal, so the recovery alone would pass. Under the lock everysudo -n -l,pgrep,launchctlandcodesign --verifycall has a 30 s limit (the sudoers check before the lock has it too). A supervising process enforces it, even if the installer is killed while it waits, and the call keeps fd 9, so the lock is held until the call has exited or been stopped: nolaunchctl bootoutorbootstrapit started is still running once the lock is released. A call past the limit gets SIGTERM, then SIGKILL one to two seconds later;sudoonly ever gets SIGTERM, and one that ignores it keeps the lock until it ends, reported with its pid. A sudoers check orpgrepthat does not answer stops the run, which releases the lock so the app and the agent can recover. Alaunchctl printthat does not answer counts as unknown, never as unloaded. Acodesign --verifythat does not answer leaves it unknown which bundle the plist on disk pins, so the run stops and moves neither bundle. uninstall.sh runs the bundle's sealed backstop.sh only aftercodesign --verify --strictpasses on the bundle (a bounded call, like its other calls under the lock). Once recovery is confirmed and print confirms the agent unloaded, it removes the bundle and install.sh's leftovers beside it, by their exact names:.Insomnia.app.previous, and.Insomnia.app.staging.<pid>.<six letters and digits>directories whose runkill -0reports gone (a live run's stays). Symlinks and other names are left. With the agent plist go the candidate plists install.sh and the app stage it from (com.insomnia.backstop.candidate-*in.com.insomnia.backstop.stagingand, from older builds, in the LaunchAgents directory). - App and script transactions must coordinate through a shared lock. Failure to acquire it must not permit an unprotected journal write or side effect.
- A
sudo pmsetis sent SIGTERM at its timeout (20 s in the app, 30 s in the agent), never SIGKILL: a killed sudo can orphan a root pmset that still changes power state later, outside any transaction. One still running 3 s after SIGTERM stops the transaction where it is, in the app as in the agent'srun_bounded: nothing else is undone, the journal keeps every entry it had, and the recovery lock stays held until the command exits. In the app the command holds the lock itself, with a descriptor on the lock file as its stdin, so a crash or force quit of the app does not free the lock while the command runs; the agent's supervising subshell keeps it the same way. That subshell, not the script that started it, enforces the limit and is the only process that signals the command. It measures the limit and the grace on bash'sSECONDSclock, so slow polls on a loaded machine do not stretch them, and it sends SIGTERM by jobspec, which reaches the command or, once the command has been reaped, nothing: never a process that reused its pid. The pid the script logs is never signaled. The subshell ignores SIGTERM and SIGHUP, so neither the end of the agent's run nor launchd's signal to what is left of the job's process group frees the lock while the command runs; a SIGKILL to the subshell would. The app runs nosudo pmsetoutside a transaction. Every one goes throughPmsetSleepGuard.sudoPmset, including a check that runs a sudoers command only to see whether it passes. It reports the pid with thesudo killcommand, in a menu line of its own that the exit removes, and refuses to quit or start a session until then. It also records the command inunfinished-command.json, with the start time and boot session read from the process table when the command was left running. The exit removes the record, and so does the next transaction that takes the lock. A transaction refused for a busy lock names the recorded command. It gives the pid andsudo killonly while the live pid still has that start time and boot session, and the first such refusal for a pid also notifies; otherwise it says the command has exited, or that the pid cannot be confirmed, and names no process to stop. An end, lid close or lid open refused meanwhile is recorded at the refusal. An undo (disablesleep 0,lowpowermode 0) that exits 0 is confirmed: its entry is cleared under the lock before the lock is released, and a display write owed for the end of the mode is done then. If the journal cannot be read or written then, the entry stays, the undo runs again, and the menu says so. Forlowpowermode 0with a journal that cannot be written, the clear is also owed, so the app takes the mode as off and does the display write at once. With a journal that cannot be read and a display write owed after the mode, by the journal last read, the clear is owed the same way and the write waits for the first transaction that reads the journal again; it is made only if that journal, with the owed edits, still owes the same value with the mode not Insomnia's. The unreadable file is not written. Any other exit confirms nothing. When the command exits the app retries a pending end. Otherwise it reads Low Power Mode under the lock. A mode that reads on stays journaled as Insomnia's. A mode that reads off is switched off once more with the app's ownlowpowermode 0, and the ownership is cleared only when that exits 0. A claim from before the Mac last started that it clears this way counts as the mode's end in this boot, as for any switch-off (section 4). A display write owed for the end of the mode is kept through the check and done then: with the mode already off, powerd's rescale of the panel cannot be told apart from a user's change, so the panel is compared with the owed value only before a switch-off, while the mode is still on. Then the app replays a refused lid event for the state of the latest lid event, after any change still in the 2 s lid debounce has settled, and runs the floor rules again. A check that cannot take the lock, read the journal or the mode, switch the mode off, or write the journal runs again after the retry delay while the session lasts. After alowpowermode 0that exited 0, the ownership clear the journal refused is owed: the retried check's transaction writes it first, and the check then has nothing to switch off. - Successful restores may clear their entries; failures must stay journaled.
A journal write that fails to clear the entry of a successful restore is
shown in the menu as well as logged, and the restore is retried. The
line goes once a later write clears that entry, unless a newer error
has replaced it.
Process recovery must verify identity and avoid resuming a process that
Insomnia did not stop. The entries that record
startedAtMicrosgo to the installed app binary in one call (Insomnia --resume-frozen <seconds>, with one line<pid> <startedAt> <startedAtMicros> <bootSession>per entry on standard input, which has no size limit, answered before AppKit starts), so the comparison is to the microsecond and each entry's signal follows its own lookup in one process. The binary prints one line per entry in input order,<pid> <word>, and exits 0 when every word isresumedorgone, 1 otherwise. A missing or malformed<seconds>(1 to 300), any further argument, empty input or a malformed line is a usage error (exit 64) that checks nothing. The script runs the binary only when the bundle'sInfo.plistdeclaresInsomniaResumeFrozenVersionequal to the version the script speaks, because an older build would start the menu bar app instead; otherwise it keeps those entries. It runs it with the same 30-second limit as a power command, then SIGTERM, then SIGKILL, and with the lock descriptor: the binary keeps the recovery lock while it can still send a signal, even if the script dies first, and ends itself with SIGALRM after<seconds>(the script's limit plus the SIGTERM grace), so the lock is freed without anyone waiting for it. The script starts the binary as its own background job and is the only process that signals it, by jobspec, so a signal never reaches a pid bash has already reaped. It checks the whole answer: one line per entry with that entry's pid and a known word and nothing else, and an exit status that agrees with the words.resumedandgoneclear an entry, the other words keep it, and a missing binary, a timeout or any other answer keeps every entry of the call. Entries without microseconds keep the shell's one-secondpscomparison. Old PID-only entries need conservative handling. - The shell does not restore CoreAudio settings. Saved audio must remain in
the journal for the app to restore. Legacy
savedOutputVolume/savedMutedcount as unresolved, so the run exits 1 and says to open Insomnia.savedAudioOutputsentries are checked for shape and kept, but on their own they leave the journal clean: an entry can wait days for its device, the app's menu shows it, and an error every minute would only fill the log. The backstop logs them once, when it removes a session or undoes something else. Uninstall must preserve recovery tools and state when restoration is incomplete, including saved audio. It runs the checkout's backstop only when the installed app declares theInsomniaResumeFrozenVersionthat backstop speaks; otherwise it runs the backstop installed with that app, when there is one: the copy sealed in its bundle, oncecodesign --verify --strictpasses, else the writable copy older installs left in Application Support. With no installed copy it runs the checkout's backstop anyway, which keeps the entries that need the binary without running it, so uninstall stops before removing anything. - The shell does not restore display or keyboard brightness either; both stay
in the journal for the app. One flagged
displayRestoreRefusedorkeyboardRestoreRefusedis kept but does not make the journal dirty, so it neither fails the run nor stops uninstall, which keeps state.json for it (section 4). - The shell puts
appNapOverridesback withdefaults write <id> NSAppSleepDisabled -bool <previous>ordefaults deletewhen the key was absent. A delete that fails counts as done only whendefaults readthen says the key does not exist; a read that succeeds or fails any other way keeps the entry. - The agent is a recovery mechanism, not a guarantee of crash/reboot behavior or a replacement for battery/thermal observers. Their real-Mac checks are in docs/releasing.md.
UNUserNotificationCenter: session ended (with reason), extend reminder 5
minutes before end, battery floor reached, battery unreadable twice in a row,
thermal action taken, network gap recovered (with nudge summary), sleep
restored by backstop, sleep disabled by something other than Insomnia
(reconcile step 3, once per launch), lid-close settings changed by the
one-time update (section 10, once).
JSON at ~/Library/Application Support/Insomnia/config.json, edited through a
small settings window. Like session.json, state.json and the recovery
lock it is created mode 0600 in a 0700 directory, and a looser file from an
older build is tightened when the app reads it:
- presets, default preset
- freeze list (bundle ids), freeze every other app on/off (default off), Docker rule on/off, mute on lid close on/off (default on). Under the mute toggle: on Mac laptops with Apple silicon or a T2 chip, closing the lid disconnects the built-in microphone in hardware, so recording with the lid closed needs AirPods or an external mic.
- one-time lid-close update. A config.json without
lidCloseDefaultsAppliedwas saved by an earlier build. At launch it getsfreezeAllAppsoff andmuteOnLidCloseon, the mark set, and is written back. When either value changed, the launch reconcile posts one "Lid-close settings changed" notification naming each change and where to change it back (Settings, Lid-close actions). It posts before taking the recovery lock, so a busy lock or an unreadable journal does not hold it back, and after the app installs its notification delegate. The app keeps the change inlidCloseDefaultsNoticeso Settings shows the same line at the top of Lid-close actions until dismissed. The mark stays set, so a setting the user turns back is never changed again. A missingmuteOnLidClosein a config without the mark reads as off, as the earlier builds read it. A fresh install writes a config.json with the mark and the new defaults and shows no notice. - agent list (bundle ids), turn App Nap off for them on/off (default off)
lowPowerFloor,endFloor, thermal rules on/off- hotspot SSID (password entered once, stored in Keychain),
nudgeThreshold - tmux targets,
tmuxNudgePressesEnter(default off) - launch at login (
SMAppService.mainApp). macOS ties the login item to the bundle's signature and location, andinstall.shad-hoc signs a fresh bundle on every run, so an upgrade can drop the registration. config.json keepslaunchAtLoginInstall, the code directory hash, bundle path and executable file identity (inode and birth time) of the install whose registration macOS last accepted. install.sh deletes the bundle and copies the executable in fresh, so even an unchanged or unsigned build reinstalled at the same path reads as a new install. At launch, with the flag on andSMAppService.mainApp.statusneither enabled nor waiting for approval, that record decides: a different install means the reinstall lost the registration and the app registers again; the same install means the user removed the item in System Settings, and the app turns the flag off rather than put it back; no record (a config from before the field) means this is the first launch of a build that keeps one, itself a reinstall, so the app registers once and records the install. A user who removed the item while Insomnia's switch stayed on gets it back that once. Every outcome is logged. The Settings switch shows what macOS has on file (enabled or waiting for approval), not the flag; a registration waiting for approval shows a note with a button that opens System Settings > General > Login Items, and turning the switch off withdraws it; a register or unregister that throws shows its error under the switch. The flag and the install are persisted only when macOS accepted the change. The status is re-read when the Settings window appears and whenever the app becomes active, so an approval or removal made in System Settings shows without a relaunch. With the flag off nothing is registered or unregistered at launch.
Reference: the attached screenshot (coffee icon, then three rounded pill fields "Hours", "Minutes", "Seconds", each with a small "?" badge, sitting directly in the menu bar). Insomnia copies that interaction and the feel.
Idle state. A single coffee-cup status item. Nothing else in the bar.
Entering a time. Click the icon and the status item expands in place along the menu bar: three pill fields spring out to the right of the icon, one after another with a short stagger.
☕ ( Days ? ) ( Hours ? ) ( Minutes ? )
- Days · Hours · Minutes rather than the reference's Hours · Minutes · Seconds. Seconds are meaningless for keeping a laptop awake and days are needed. (Flip this in one line if you want the reference exactly.)
- Each pill is a numeric field. Placeholder text is the unit name; typing replaces it with the number and the pill grows to fit. Tab and Shift-Tab move between pills, Enter starts the session, Esc collapses.
- The "?" badge on each pill is a help affordance: hover shows a tooltip ("Up to 30 days" etc.). It is not an input.
- The current interface uses inline entry, not the preset-popover proposal from the original design. Empty-field Enter starts the default preset.
Running state. On Enter the pills collapse and morph into a compact second-resolution countdown next to the icon. Clicking the cup or countdown opens inline duration entry for an extension. Hold the end control to end the session. The right-click menu contains status lines (lid, watts, Wi-Fi, frozen apps, Docker), browser relaunch actions, Settings, and Quit.
Battery watts are read from AppleSmartBattery (InstantAmperage ×
Voltage) on demand when status is requested. Never continuously polled.
Motion and feel. This is a hard requirement, not polish.
- Everything animates with springs, except the fold that closes the pills
(a fixed-duration ease, so the slots can leave the moment the last pill
is gone), the pupil shrinking away as the lid drops (a 0.4 s ease-out,
no bounce) and the Reduce Motion crossfades.
Baseline:
.spring(response: 0.35, dampingFraction: 0.72); pill focus bounce and chip taps use a snappier.spring(response: 0.25, dampingFraction: 0.6)with a slight scale overshoot (1.0 → 1.06 → 1.0). - The status item's width is never animated:
NSStatusItem.lengthis written once per layout change (StatusWidthWriter), to the width the SwiftUI layout of a customNSStatusItemview reports once per open and once per close, as every Apple item does. (Every length write makes Control Center re-lay out the menu bar and blocks the app's next render commit for 8–130 ms, so any per-frame width animation stutters; see the menu bar smoothness design spec, revision 4.) Layout-changing state (the phase, the pill slots, the error label) is set outside any animation transaction, so the layout is computed once per state; the hosting view is laid out at that width, anchored at the leading edge, and the item's window reveals or clips it. Only scale, offset and opacity animate inside. - Each pill is a fixed slot sized by its placeholder at the typed weight and padding, so typing a digit never changes the layout.
- Pills appear with a staggered scale-and-fade (about 40 ms between pills) inside their slots, which are all in the layout from the first frame.
- On Esc and on Enter the pills fold toward the eye: each one slides left by the slots before it, shrinks to 0.6 from its leading edge and fades, on one 0.32 s ease curve, farthest pill first, 40 ms apart, sliding under its neighbour. The slots leave in one relayout 0.36 s after the last pill's step, and the width is written then, never across a visible pill. On Enter the eye starts to open at once and the countdown scales in from the leading edge after the slots have left, showing the time the session will read once the manager confirms it and ticking at 1 Hz meanwhile; the hold-to-end ring follows the confirmation. A click on the eye during an Esc fold brings the pills back; during an Enter fold the pending start owns the bar and the click does nothing until the manager answers. Transitions carry their own animation, so they run whatever transaction the layout change lands in.
- The eye's blink is a slower spring (response 0.95, damping fraction 0.9 opening; 0.8 / 0.95 closing) so the lid lift and the lash hand-over are seen; the pupil scales in from 0.6 on its own bouncier spring 0.2 s later and eases out to 0.6 on close, clipped to the lens.
- Typing happens in a non-activating key panel: the app in front stays frontmost; only key status moves to the pills while typing, as with Spotlight, and it returns when the pills close.
- Number changes in the countdown use
.contentTransition(.numericText()). - Focus ring is a soft glow that breathes in, not a hard outline.
- Respect Reduce Motion: springs become short crossfades (0.15 s; the blink 0.3 s), the fold is opacity only in place (0.2 s), the stagger is zero.
- Rendering matches the reference: dark rounded pills with a subtle material, system font, SF Symbols icon, no custom images.
Reference for taste: Apple's own Dynamic Island and Control Center transitions. If it feels like a web dropdown, it is wrong.
| input | mechanism | cost between events |
|---|---|---|
| lid | IOKit interest notification | none |
| battery % | IOPS run loop source | none |
| battery unreadable | re-read every 30 s | one wake per 30 s, only while a session runs and the battery is unreadable |
| thermal | ProcessInfo notification |
none |
| network path | NWPathMonitor |
none |
| session deadline | one in-app timer plus independent launchd recovery | recovery checks may wake periodically |
| countdown redraw | 1 Hz timer, stopped while lid closed | one wake per second while active and visible |
| hotspot retry | only during an outage | none otherwise |
Insomnia/
Package.swift
Sources/Insomnia/
InsomniaApp.swift
Model/
Config.swift
RuntimeState.swift
Session.swift
Store/
Paths.swift
Store.swift
Core/
AppServices.swift
FloorRules.swift
LaunchdBackstop.swift
LidActions.swift
Log.swift
ProcessControl.swift
SessionManager.swift
SessionMath.swift
Shell.swift
SleepGuard.swift
System/
AppNap.swift
AudioControl.swift
BrowserThrottle.swift
DisplayPower.swift
DockerRule.swift
Freezer.swift
HotspotJoiner.swift
LidObserver.swift
LidSimulation.swift
LocationPermission.swift
NetworkFailover.swift
Notifier.swift
PowerMonitor.swift
ShellTimeout.swift
TmuxNudge.swift
UI/
DurationInput.swift
HoldToEndButton.swift
HotspotSecretStore.swift
LiveStatusSource.swift
MenuBarModel.swift
Motion.swift
PillView.swift
ReminderScheduler.swift
StatusMenu.swift
SettingsView.swift
StatusItemController.swift
StatusRootView.swift
StatusSource.swift
Tests/InsomniaTests/
BrowserThrottleTests.swift
ConfigTests.swift
DisplayPowerTests.swift
DurationInputTests.swift
FailoverMachineTests.swift
FloorRulesTests.swift
FreezerTests.swift
HardwarePortsParserTests.swift
IntegrationWiringTests.swift
LaunchdBackstopTests.swift
LidActionsTests.swift
PmsetParsingTests.swift
ReconcileLidGatingTests.swift
ReconcileTests.swift
SessionMathTests.swift
StoreTests.swift
TestSupport.swift
UIStatusTests.swift
scripts/
build-app.sh build, bundle (backstop.sh sealed inside), codesign
install.sh build-app.sh or a verified --app bundle, sudoers, launchd
uninstall.sh reverse all of the above, restore sleep
backstop.sh standalone restore from JSON
simulate-lid.sh file trigger for the lid-close action path (debug and
INSOMNIA_LID_SIMULATION=1 builds only)
docs/spec.md
docs/install.md install, upgrade and uninstall steps
docs/lid-close.md lid-close actions and their defaults
docs/recovery.md recovery details and manual attention
docs/extras.md hotspot, tmux, Chrome, configuration and privacy
skills/ agent playbooks: install-insomnia, maintain-insomnia
README.md short overview that links the docs
git clone https://github.com/krishhgg/Insomnia.git && cd Insomnia
./scripts/install.sh # asks for sudo once, for the sudoers file
Then set the hotspot in Settings, pick a freeze list, and start a session.
Run under supervision on a ventilated surface using disposable work. The checklist below is a test plan; record a release's results in the release validation record.
- First launch. Confirm the status item is visible to the right of the notch on first launch.
- Stays awake. Start 30m session, close lid, wait 5 minutes, ping the Mac from the phone or check the heartbeat log. Open lid: session still running, sleep still disabled until end.
- Restores. End now →
pmset -gshows noSleepDisabled. Quit → same. Timer expiry → same, plus notification. - Backstop. Force-quit a supervised disposable session, then verify deadline recovery and retry after an injected restore failure. Separately test reboot/login with valid, expired, and dirty journals; the polling agent honors a valid future deadline rather than unconditionally ending every session at login. Saved audio requires the app to reopen.
- Freeze. Slack and WhatsApp on list, close lid,
ps -o statshowsTfor their whole trees. Open lid → running, reconnected, no relaunch. - Docker rule. Rule on. No containers → paused on close; insomnia.log has "first check found no running container" and "second check found no running container". One container → untouched, log has "first check found containers running". A container started between the two checks → untouched, log has the first check finding none and "second check found containers running".
- Mute. Volume 60%, close lid → muted. Open → 60%, unmuted. With a headset as the output, close the lid, unplug the headset, open the lid and choose End: the notification and the menu name the headset as still muted. Plug it back in → its volume comes back and the menu line goes. Repeat, but Quit with the headset unplugged, plug it in, then launch Insomnia → restored at launch.
- Chrome occlusion. Lid closed, Playwright attached to headed Chrome:
read
document.visibilityStateand measuresetIntervaldrift. Repeat with both flags. Decide whether feature 5's browser section stays. - Handoff and Location. Save a hotspot for the first time and confirm the
Location permission prompt appears. After granting, confirm the status menu
shows the SSID. Turn off the router or walk away, watch
handoffs.log, and confirm the hotspot join works within ~10 s and a Claude Code turn in flight completes. - Nudge. Mark a disposable pane (
tmux set-option -p -t <target> @insomnia-nudge on). Gap forced above threshold → that pane receives "continue" and no Enter; an unmarked listed pane receives nothing; with "Press Enter after continue" on, the line is submitted. Notification posted. - Floors. Set
lowPowerFloorabove current charge → Low Power Mode on. Plug in charger → off. SetendFloorabove current charge → session ends. - Thermal. Exercise injected thermal events first; verify responses to
serious,critical, and recovery. Do not intentionally overheat the Mac. - Darken. Brightness 70%, keyboard backlight on, close lid → both go to
0 (check
state.jsonhassavedDisplayBrightnessandsavedKeyboardBrightness). Open → both back, journal entries gone. Repeat with the lid open usingscripts/simulate-lid.sh closedthenopenduring a session; the log showslid SIMULATED closed (file trigger). That needs a build with the watcher compiled in (installed withINSOMNIA_LID_SIMULATION=1 ./scripts/install.sh, whichbuild-app.shreads; install.sh refuses it with--app; it logs "Lid simulation build" at launch). A normal install ignores the trigger: the watcher is compiled out so a file written by any other program running as the user cannot replay the lid actions. CI proves that on the binaries:scripts/check-lid-simulation-gate.shbuilds the release both ways and checks the watcher class and its log lines are absent from the plain binary and present with the define. A binary nm or strings cannot read fails the check rather than counting as absent. Quit while closed → both restored. Force-quit while closed, reopen the app → restored at reconcile, andbackstop.shalone leaves both keys in place. - App Nap. With the setting on and Terminal on the agent list,
defaults delete com.apple.Terminal NSAppSleepDisabled, start a session →defaults readshows 1 andstate.jsonhas anappNapOverridesentry withoutprevious. End → the key is gone, entry gone. Repeat with the key set to 0 → put back to 0. Force-quit during a session →backstop.shalone puts it back. Set the key to 1 by hand, emptyappNapOverrides, rununinstall.sh→ it prints thedefaults deletecommand and continues. - Meeting apps. Freeze-all on, a Zoom or Teams call running on AirPods
and Wispr Flow or Granola taking notes. Close the lid → none of their
processes shows
Tinps -o stat, the call and the notes continue, other Dock apps are frozen, sound is muted. - Upgrade notice. Install over a build whose config.json has
freezeAllApps: trueandmuteOnLidClose: false. First launch → one "Lid-close settings changed" notification, the same line at the top of Lid-close actions in Settings, both toggles changed, config.json haslidCloseDefaultsApplied: true. Turn freeze-all back on, relaunch → no notice, still on. Dismiss removes the Settings line. A fresh install shows no notice.
pmset -a(all power sources) rather than-bfordisablesleep, so behaviour is identical whether or not a charger is attached.- Default
lowPowerFloor40%,endFloor10%,nudgeThreshold90 s. - Turning App Nap off for agent apps is opt-in and the previous value is put back at session end. Values older builds wrote without a record are listed by uninstall, never deleted by it.