From 2f813c2538414fcf2303e1bd09431a90e231f02c Mon Sep 17 00:00:00 2001
From: Farnood
Date: Sun, 7 Jun 2026 12:30:18 -0400
Subject: [PATCH 1/4] Harden sudoers setup flow
Move in-app grant setup away from executing bundled scripts as root, bind the sudoers rule to a numeric UID, and document the updated security model.
Co-authored-by: Cursor
---
App.swift | 70 ++++++++++++++++++++------
CONTEXT.md | 22 ++++++++
README.de.md | 2 +-
README.es.md | 2 +-
README.fr.md | 2 +-
README.ja.md | 2 +-
README.md | 2 +-
README.zh-CN.md | 2 +-
SECURITY.md | 34 ++++++++-----
build.sh | 5 +-
docs/AUDIT.md | 22 ++++----
docs/adr/0001-local-agent-detection.md | 3 ++
grant.sh | 39 +++++++-------
install.sh | 4 +-
sleepless.sudoers.template | 6 +--
15 files changed, 148 insertions(+), 69 deletions(-)
create mode 100644 CONTEXT.md
create mode 100644 docs/adr/0001-local-agent-detection.md
diff --git a/App.swift b/App.swift
index 4c4d254..e606276 100644
--- a/App.swift
+++ b/App.swift
@@ -36,6 +36,7 @@
// File MUST be named App.swift and compiled -parse-as-library so the
// @main enum + @MainActor static main() entry is Swift-6 isolation-safe.
import AppKit
+import Darwin
import ServiceManagement
// MARK: - Tunables
@@ -45,6 +46,8 @@ private let floorKey = "batteryFloorPercent"
private let floorDefault = 15
private let floorMin = 5
private let floorMax = 50
+private let sudoersDropInPath = "/etc/sudoers.d/sleepless-disablesleep"
+private let sudoersCommandGrant = "ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
// MARK: - Menu-bar coffee glyph (native SF Symbols, MONOCHROME template — state by SHAPE)
// macOS convention: a menu-bar extra is a template image (no colour) so it adapts to light/dark
@@ -388,9 +391,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
// Install the one-time scoped grant via a SINGLE native macOS authorization (the
- // standard Touch ID / password sheet) — no Terminal. Runs the bundled, audited
- // grant.sh as root through osascript's "with administrator privileges"; grant.sh is
- // root-aware so it writes the sudoers drop-in directly with no inner sudo prompt.
+ // standard Touch ID / password sheet) — no Terminal. The privileged script is
+ // generated from constants baked into this binary, not loaded from the mutable app
+ // bundle, then validated with visudo before installation.
// Returns true once the passwordless grant is in place; after that the app never asks again.
@discardableResult
private func installGrantViaAuth() -> Bool {
@@ -403,27 +406,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
NSApp.activate(ignoringOtherApps: true)
guard intro.runModal() == .alertFirstButtonReturn else { return false }
- guard let res = Bundle.main.resourcePath else { return false }
- let grant = res + "/grant.sh"
- // Pass the REAL user: under the native auth sheet grant.sh runs as root with
- // SUDO_USER unset, so without this the grant would be written for "root" (useless).
- let shellCmd = "SLEEPLESS_USER='\(NSUserName())' /bin/bash '\(grant)' --yes"
- // escape for an AppleScript string literal, then run with one native auth sheet
- let escaped = shellCmd.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
- let osa = "do shell script \"\(escaped)\" with administrator privileges"
+ guard let userSpec = sudoersUserSpec() else {
+ notify("Couldn't set up permission: unsupported user ID.")
+ return false
+ }
+
+ let grant = "\(userSpec) \(sudoersCommandGrant)"
+ let installScript = [
+ "set -euo pipefail",
+ "tmp=\"$(/usr/bin/mktemp)\"",
+ "trap '/bin/rm -f \"$tmp\"' EXIT",
+ "/usr/bin/printf '%s\\n' \(shellSingleQuoted(grant)) > \"$tmp\"",
+ "/usr/sbin/visudo -cf \"$tmp\" >/dev/null",
+ "/usr/bin/install -m 0440 -o root -g wheel \"$tmp\" \(shellSingleQuoted(sudoersDropInPath))",
+ "/usr/sbin/visudo -c >/dev/null"
+ ].joined(separator: "; ")
+ let shellCmd = "/bin/bash -c \(shellSingleQuoted(installScript))"
+ let osa = "do shell script \(appleScriptStringLiteral(shellCmd)) with administrator privileges"
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
proc.arguments = ["-e", osa]
- proc.standardOutput = Pipe(); proc.standardError = Pipe()
- do { try proc.run(); proc.waitUntilExit() }
+ proc.standardOutput = FileHandle.nullDevice
+ let errPipe = Pipe()
+ proc.standardError = errPipe
+ let errData: Data
+ do {
+ try proc.run()
+ errData = errPipe.fileHandleForReading.readDataToEndOfFile()
+ proc.waitUntilExit()
+ }
catch { notify("Couldn't start the one-time setup."); return false }
- if proc.terminationStatus == 0 { return true } // grant.sh installed the rule successfully
+ if proc.terminationStatus == 0 { return true } // sudoers drop-in installed successfully
if proc.terminationStatus != 128 { // 128 = user cancelled the auth sheet
+ let err = String(data: errData, encoding: .utf8) ?? ""
+ if !err.isEmpty { NSLog("Sleepless setup failed: %@", err) }
notify("Setup didn't complete. Try again, or run grant.sh from the app bundle.")
}
return false
}
+ private func sudoersUserSpec() -> String? {
+ let uid = getuid()
+ guard uid > 0 else { return nil }
+ return "#\(uid)"
+ }
+
+ private func shellSingleQuoted(_ s: String) -> String {
+ "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+
// A brief, subtle pulse on the menu-bar glyph whenever the state (and thus the cup
// shape) changes, so the change is noticeable. Opacity-only: no layer geometry is
// mutated, so it can't shift the status item on any macOS version.
@@ -663,10 +694,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// MARK: - Notification (mirrors Nexus' osascript approach)
private func notify(_ message: String) {
- let script = "display notification \"\(message)\" with title \"Sleepless\" sound name \"Tink\""
+ let script = "display notification \(appleScriptStringLiteral(message)) with title \(appleScriptStringLiteral("Sleepless")) sound name \(appleScriptStringLiteral("Tink"))"
_ = runCapture("/usr/bin/osascript", ["-e", script])
}
+ private func appleScriptStringLiteral(_ s: String) -> String {
+ let escaped = s
+ .replacingOccurrences(of: "\\", with: "\\\\")
+ .replacingOccurrences(of: "\"", with: "\\\"")
+ .replacingOccurrences(of: "\r", with: "\\r")
+ .replacingOccurrences(of: "\n", with: "\\n")
+ return "\"\(escaped)\""
+ }
+
// MARK: - Process runner (explicit PATH/HOME; captures stdout)
@discardableResult
private func runCapture(_ launchPath: String, _ args: [String]) -> String {
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 0000000..2d71648
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,22 @@
+# Context
+
+## Glossary
+
+- **Keep-awake state**: The user-controlled state where Sleepless keeps the Mac awake when it would otherwise sleep.
+- **Agent auto-off**: A safety cutoff that may end the keep-awake state when no monitored agents are active. It does not start or re-enable the keep-awake state.
+- **Active agent**: A monitored coding-agent session that Sleepless can detect through local CLI, process, or session signals without reading another app's UI. A live session counts as active even when it is waiting for input or approval. Detection may use at most one optional, non-screen macOS permission when it provides reliable non-UI signals.
+- **Locally observable agent**: Agent work with a local process, worker, session signal, or integration heartbeat. Cloud-only agent work without a local signal is outside Sleepless' monitoring contract.
+- **Monitored agent tool**: An installed coding-agent tool for which Sleepless has a reliable local detector. Tools without reliable detectors are not shown in agent status and do not affect agent auto-off.
+- **Installed agent tool**: A coding-agent tool discovered through bounded, tool-specific signals such as a validated CLI executable, a known app bundle identifier, or an official local integration. Sleepless does not use exhaustive filesystem searches to discover tools.
+- **Agent integration**: An app-wide, opt-in integration such as a hook or heartbeat that helps Sleepless detect active agents without UI scraping. Project-by-project integrations are too high-friction to be required, and app-wide integrations are required only when default local detection is not reliable enough.
+- **Healthy agent detection**: The state where Sleepless has the required local signals and any required permission to evaluate at least one monitored agent tool. Agent auto-off starts inactive, asks for required permission only when the user enables it, and stays enabled while at least one monitored tool is available.
+- **Agent status**: The user-visible state of a monitored agent tool, shown as Active, Idle, or Setup needed.
+- **Agent setup**: A per-tool action in the controls popover that sets up required app-wide integrations or prompts for required permission. Detailed explanation lives in documentation rather than a setup wizard.
+- **No agent tools available**: The state where Sleepless finds no monitored agent tools. The controls popover explains that no supported agent tools were found, and agent auto-off is unavailable.
+- **Controls popover**: The single menu-bar popover where Sleepless exposes keep-awake controls, safety cutoffs, and monitored agent status.
+- **Agent coffee logo**: The robot-with-coffee brand mark used for app and marketing identity. The menu bar uses a simplified monochrome template glyph derived from the same idea.
+- **Native lightweight app**: Sleepless remains a small native macOS menu-bar app, but implementation may be split across focused Swift files when features are too broad for a single source file.
+- **No internet connection**: A sustained inability to reach the public internet across consecutive checks, determined from macOS network path status plus a lightweight HTTPS reachability probe.
+- **Realtime agent status**: A visible status that updates every three seconds while the user is looking at the controls.
+- **Auto-off grace period**: A two-minute delay before an agent or internet safety cutoff acts, used to avoid turning off during transient network drops, tool restarts, or session handoffs.
+- **Safety cutoff**: Any enabled condition that may end the keep-awake state. Safety cutoffs combine independently; any one of them may turn Sleepless off. New cutoffs default off until the user enables them.
diff --git a/README.de.md b/README.de.md
index 962fddd..13bfd0d 100644
--- a/README.de.md
+++ b/README.de.md
@@ -99,7 +99,7 @@ Klicke dann auf die Tasse in der Menüleiste, lege den Schalter um und schließe
Sleepless schaltet `pmset disablesleep` um (das `SleepDisabled`-Flag des Kernels), liest es zurück, sodass die Menüleiste nie lügt, und setzt es bei deinem Akku-Mindeststand, im Low Power Mode, beim Ablaufen des Timers oder beim Neustart zurück. Eine GUI-App kann kein Passwort eintippen, deshalb fügt das Installationsprogramm eine eng gefasste sudoers-Regel für **genau zwei Befehle** hinzu:
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **Lässt sich nicht ausweiten.** sudoers gleicht Argumente wörtlich ab, ohne Platzhalter.
diff --git a/README.es.md b/README.es.md
index f7aa1bb..a440042 100644
--- a/README.es.md
+++ b/README.es.md
@@ -99,7 +99,7 @@ Luego haz clic en la taza de la barra de menús, activa el interruptor y cierra
Sleepless activa `pmset disablesleep` (el indicador `SleepDisabled` del kernel), vuelve a leerlo para que la barra de menús nunca mienta, y lo revierte en tu nivel mínimo de batería, en Low Power Mode, cuando el temporizador termina o al reiniciar. Una app gráfica no puede escribir una contraseña, así que el instalador añade una regla de sudoers de alcance reducido para **exactamente dos comandos**:
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **No se puede ampliar.** sudoers coincide con los argumentos de forma literal, sin comodines.
diff --git a/README.fr.md b/README.fr.md
index edb43bb..6e93f21 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -99,7 +99,7 @@ Cliquez ensuite sur la tasse dans la barre des menus, basculez l'interrupteur et
Sleepless bascule `pmset disablesleep` (le drapeau `SleepDisabled` du noyau), le relit pour que la barre des menus ne mente jamais, et le rétablit à votre plancher de batterie, en mode Économie d'énergie, à la fin de la minuterie ou au redémarrage. Une application graphique ne peut pas saisir de mot de passe, alors l'installateur ajoute une règle sudoers au périmètre strict pour **exactement deux commandes** :
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **Impossible à élargir.** sudoers compare les arguments littéralement, sans jokers.
diff --git a/README.ja.md b/README.ja.md
index bb89c82..2db7226 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -99,7 +99,7 @@ brew install --cask aboudjem/tap/sleepless
Sleepless は `pmset disablesleep`(カーネルの `SleepDisabled` フラグ)を切り替え、値を読み戻すのでメニューバーが嘘をつくことはなく、バッテリー下限に達したとき、Low Power Mode のとき、タイマーが切れたとき、または再起動時に元へ戻します。GUI アプリはパスワードを入力できないため、インストーラーは**ちょうど 2 つのコマンド**だけを許可する、範囲を絞った sudoers ルールを追加します。
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **範囲を広げられません。** sudoers はワイルドカードなしで引数を文字どおり照合します。
diff --git a/README.md b/README.md
index dda0727..972c743 100644
--- a/README.md
+++ b/README.md
@@ -99,7 +99,7 @@ Then click the cup in the menu bar, flip the switch, and close the lid.
Sleepless toggles `pmset disablesleep` (the kernel's `SleepDisabled` flag), reads it back so the menu bar never lies, and reverts it at your battery floor, in Low Power Mode, when the timer ends, or on reboot. A GUI app can't type a password, so the installer adds a scoped sudoers rule for **exactly two commands**:
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **Can't be widened.** sudoers matches arguments literally, no wildcards.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 19b66ff..31e407d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -101,7 +101,7 @@ brew install --cask aboudjem/tap/sleepless
Sleepless 切换 `pmset disablesleep`(内核的 `SleepDisabled` 标志),把它读回来让菜单栏绝不撒谎,并在到达你的电量下限、进入 Low Power Mode、定时器结束或重启时把它还原。GUI 应用没法输入密码,所以安装程序会加一条范围严格限定的 sudoers 规则,**只允许两条命令**:
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **无法被放宽。** sudoers 按字面匹配参数,没有通配符。
diff --git a/SECURITY.md b/SECURITY.md
index a3b0264..7675308 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -36,18 +36,21 @@ reality rather than assuming the command worked.
## The passwordless grant — exactly what it permits
A GUI app has no terminal to type a password into, so Sleepless runs `pmset` through a
-tightly scoped `/etc/sudoers.d` drop-in. `install.sh` writes this (with your username
-substituted for `__USER__`), owned `root:wheel`, mode `0440`:
+tightly scoped `/etc/sudoers.d` drop-in. The app's one-time native setup, `install.sh`,
+and `grant.sh` all install the same rule (with your numeric UID substituted for `__UID__`),
+owned `root:wheel`, mode `0440`:
```
- ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
-**This grant lets one user run, as root, exactly two fully-specified commands and nothing
-else.** sudoers matches command arguments *literally* — and this rule contains **no
-wildcards** — so the match is total. From the sudoers manual: *"If a Cmnd has associated
+For example, a typical first local account might install as `#501 ALL=(root) ...`.
+
+**This grant lets one UID run, as root, exactly two fully-specified commands and nothing
+else.** sudoers matches command arguments _literally_ — and this rule contains **no
+wildcards** — so the match is total. From the sudoers manual: _"If a Cmnd has associated
command line arguments, then the arguments in the Cmnd must match exactly those given by
-the user on the command line (or match the wildcards if there are any)."*
+the user on the command line (or match the wildcards if there are any)."_
Consequences you can rely on:
@@ -57,16 +60,21 @@ Consequences you can rely on:
- Sleepless calls `sudo` with an **argv array**, not a shell string
(`Process.arguments` in `App.swift`), so there is no `/bin/sh -c`, no command
substitution, and no word-splitting surface inside the app.
-- There is **no helper script**. The classic sudoers footgun is a *user-writable* script
- that root executes — rewrite it, get root. Sleepless points the rule directly at Apple's
- `/usr/bin/pmset`, and the sudoers file itself is `root:wheel 0440` (you cannot modify it
- without `sudo`). Both mitigations are exactly what the literature prescribes.
+- The ongoing passwordless grant points directly at Apple's `/usr/bin/pmset`, not at a
+ helper script. The classic sudoers footgun is a _user-writable_ script that root executes
+ on every privileged action — rewrite it, get root. Sleepless avoids that: the rule itself
+ is `root:wheel 0440`, has no wildcards, and can only invoke the two `pmset` argument
+ vectors above.
+- During the app's one-time native setup, the root-authenticated command is generated from
+ constants baked into the app binary and validated with `visudo` before installation; it
+ does **not** execute the bundled `grant.sh` as root. `grant.sh` remains available for
+ manual installs from a clone or app bundle.
## Honest residual risk
The grant is passwordless **by design**: any process already running as your user can flip
the sleep flag silently. We are not pretending the attack surface is zero. But the worst
-case is *"your Mac was kept awake, or allowed to sleep."* It is **not** data exfiltration
+case is _"your Mac was kept awake, or allowed to sleep."_ It is **not** data exfiltration
and **not** root code execution — the two pinned arguments to one Apple binary do not
provide either.
@@ -86,7 +94,7 @@ that flips the flag back to `0` while the Mac is awake and discharging, so a for
## Code signing, notarization, and Gatekeeper
Sleepless is **ad-hoc signed and not notarized** — it has no paid Apple Developer ID. The
-trust model is *read the source, build it yourself*. (Notarization is also not a malware
+trust model is _read the source, build it yourself_. (Notarization is also not a malware
guarantee: signed, notarized macOS stealers have shipped.)
- **Build from source (recommended):** locally compiled apps are **not quarantined**, so
diff --git a/build.sh b/build.sh
index 156d041..9309af5 100755
--- a/build.sh
+++ b/build.sh
@@ -73,9 +73,10 @@ cp "$REPO/grant.sh" "$REPO/uninstall.sh" "$CONTENTS/Resources/"
chmod +x "$CONTENTS/Resources/grant.sh" "$CONTENTS/Resources/uninstall.sh"
rm -rf "$BIN_TMP"
-# 4. Ad-hoc sign (no Apple Developer ID needed; trust comes from building it yourself).
+# 4. Ad-hoc sign with hardened runtime enabled (no Apple Developer ID needed; trust
+# comes from building it yourself).
echo "==> Ad-hoc signing"
-codesign --force --deep --sign - "$APP"
+codesign --force --deep --options runtime --sign - "$APP"
codesign --verify --verbose=1 "$APP" 2>&1 | sed 's/^/ /' || true
echo ""
diff --git a/docs/AUDIT.md b/docs/AUDIT.md
index 8fa12b9..38f6f57 100644
--- a/docs/AUDIT.md
+++ b/docs/AUDIT.md
@@ -2,7 +2,7 @@
Sleepless asks for a narrow slice of root, so it should be easy to check, not taken on
faith. This page is the practical companion to [SECURITY.md](../SECURITY.md): the latter
-explains *why* the design is safe, this one shows you *how to confirm it yourself* and how
+explains _why_ the design is safe, this one shows you _how to confirm it yourself_ and how
to verify a download you did not build.
There is no Apple account behind any of this. Every check below is free and runs on your
@@ -12,12 +12,12 @@ machine.
The whole app is one file. To satisfy yourself it does what it claims and nothing else:
-| Read | What you are checking |
-|---|---|
-| [`App.swift`](../App.swift) | The only thing it runs as root is `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). No network calls, no file writes outside `UserDefaults`, no shell strings. |
-| [`sleepless.sudoers.template`](../sleepless.sudoers.template) / [`grant.sh`](../grant.sh) | The passwordless grant permits exactly those two fully-specified commands, no wildcards, installed `root:wheel 0440`. |
-| [`build.sh`](../build.sh) | `swiftc` + a hand-assembled, ad-hoc-signed bundle. No downloaded blobs, no install-time scripts baked into the binary. |
-| [`uninstall.sh`](../uninstall.sh) | Removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset …` prompts again. |
+| Read | What you are checking |
+| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [`App.swift`](../App.swift) | Steady-state privilege is only `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). The one-time native setup generates the sudoers drop-in from binary constants, validates it with `visudo`, and does not run bundled scripts as root. No network calls. |
+| [`sleepless.sudoers.template`](../sleepless.sudoers.template) / [`grant.sh`](../grant.sh) | Manual setup path: the passwordless grant permits exactly those two fully-specified commands for the local numeric UID (`#501`-style), has no wildcards, and installs `root:wheel 0440`. |
+| [`build.sh`](../build.sh) | `swiftc` + a hand-assembled, ad-hoc-signed bundle with hardened runtime enabled. No downloaded blobs, no install-time scripts baked into the binary. |
+| [`uninstall.sh`](../uninstall.sh) | Removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset …` prompts again. |
The single privileged file on your system is `/etc/sudoers.d/sleepless-disablesleep`. Read
it, and `sudo rm` it any time to revoke everything.
@@ -39,7 +39,7 @@ gh attestation verify Sleepless-.zip -R Aboudjem/Sleepless
What each one proves:
- **`shasum -c`** proves the file was not altered after publishing. It says nothing about
- *who* built it, so it is necessary but not sufficient on its own.
+ _who_ built it, so it is necessary but not sufficient on its own.
- **`gh attestation verify`** proves the file came out of this project's release workflow
(a specific repository + commit + workflow), cryptographically, with no shared secret to
leak. This is the strong link from "the source you can read" to "the binary you ran." It
@@ -74,7 +74,7 @@ Caveats, stated honestly:
release runner (`macos-latest`). A different compiler version will produce a different,
still-correct binary. The release job prints its toolchain in the **Toolchain** step so you
can match it.
-- The **signed** `.app` is only *likely* reproducible: ad-hoc code signatures embed
+- The **signed** `.app` is only _likely_ reproducible: ad-hoc code signatures embed
non-deterministic data, so compare the unsigned Mach-O above, not the signed bundle. See
the [Reproducible Builds definition](https://reproducible-builds.org/docs/definition/).
@@ -94,7 +94,7 @@ curl -s --request POST --url https://www.virustotal.com/api/v3/files \
# …then open the returned analysis URL, or just drag the zip onto virustotal.com.
```
-Note: ad-hoc-signed, unnotarized binaries draw more *heuristic* flags than notarized ones, so
+Note: ad-hoc-signed, unnotarized binaries draw more _heuristic_ flags than notarized ones, so
read any detection in context. A clean result is reassuring, not absolute; pair it with the
attestation above.
@@ -126,5 +126,5 @@ Prerequisite: [Apple Developer Program, $99/yr](https://developer.apple.com/prog
and a "Developer ID Application" certificate. Notarization removes the
"Apple could not verify this app" first-launch block; it does not change anything about how
the app works. The `/etc/sudoers.d` install step is what makes Sleepless ineligible for the
-Mac App **Store**, but it does not block notarized *direct* distribution (notarization is an
+Mac App **Store**, but it does not block notarized _direct_ distribution (notarization is an
automated malware scan, not a behavioral policy review).
diff --git a/docs/adr/0001-local-agent-detection.md b/docs/adr/0001-local-agent-detection.md
new file mode 100644
index 0000000..090aa63
--- /dev/null
+++ b/docs/adr/0001-local-agent-detection.md
@@ -0,0 +1,3 @@
+# Local Agent Detection
+
+Sleepless monitors only locally observable coding-agent work: CLI processes, local workers, session signals, or app-wide hook/heartbeat integrations. It deliberately avoids UI scraping, broad filesystem searches, Screen Recording, and cloud-only vendor API monitoring because agent auto-off is a power-control safety feature; guessing from private UI or remote state would be brittle, privacy-sensitive, and easy to misrepresent to users. Tools participate only when Sleepless has a bounded, reliable detector for them, and app-wide integrations are required only when default local detection is not reliable enough.
diff --git a/grant.sh b/grant.sh
index 39559b1..10fe8d5 100755
--- a/grant.sh
+++ b/grant.sh
@@ -9,31 +9,36 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SUDOERS_DST="/etc/sudoers.d/sleepless-disablesleep"
-# Resolve the REAL user. Prefer SLEEPLESS_USER (the app passes it, because under the native
-# auth sheet this script runs as root with SUDO_USER unset), then SUDO_USER, then the caller.
-USER_NAME="${SLEEPLESS_USER:-${SUDO_USER:-$(id -un)}}"
+# Resolve the target user. Prefer SLEEPLESS_USER for manual overrides, then SUDO_USER,
+# then the caller.
+USER_NAME="${SLEEPLESS_USER:-${SUDO_USER:-$(/usr/bin/id -un)}}"
# Never install a root-owned grant (it is useless and not what the user wants): if we somehow
# resolved to root/empty, fall back to the GUI console user, and refuse if still unresolved.
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "root" ]; then
- USER_NAME="$(stat -f%Su /dev/console 2>/dev/null || true)"
+ USER_NAME="$(/usr/bin/stat -f%Su /dev/console 2>/dev/null || true)"
fi
if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "root" ]; then
echo "error: could not resolve a non-root user for the grant; refusing to install." >&2
exit 1
fi
+USER_UID="$(/usr/bin/id -u "$USER_NAME" 2>/dev/null || true)"
+if [[ ! "$USER_UID" =~ ^[0-9]+$ ]] || [ "$USER_UID" = "0" ]; then
+ echo "error: could not resolve a non-root UID for '$USER_NAME'; refusing to install." >&2
+ exit 1
+fi
-# Run privileged steps with sudo normally, but directly when we are ALREADY root (e.g. the
-# app installs this via one native macOS auth sheet, so there is no Terminal + no sudo prompt).
-SUDO="sudo"
-[ "$(id -u)" -eq 0 ] && SUDO=""
+# Run privileged steps with sudo normally, but directly when we are already root.
+SUDO=(/usr/bin/sudo)
+[ "$(/usr/bin/id -u)" -eq 0 ] && SUDO=()
# Source of truth for the grant line: the repo template if present, else the identical
# inline string (when this script ships inside the .app bundle, no template is alongside).
TEMPLATE="$SCRIPT_DIR/sleepless.sudoers.template"
if [ -f "$TEMPLATE" ]; then
- GRANT="$(sed "s/__USER__/$USER_NAME/" "$TEMPLATE")"
+ GRANT="$(< "$TEMPLATE")"
+ GRANT="${GRANT//__UID__/$USER_UID}"
else
- GRANT="$USER_NAME ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
+ GRANT="#$USER_UID ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
fi
echo "Sleepless will install this passwordless grant at $SUDOERS_DST (root:wheel, 0440):"
@@ -46,13 +51,13 @@ if [ "${1:-}" != "--yes" ] && [ "${1:-}" != "-y" ]; then
case "$reply" in [yY]*) ;; *) echo "Aborted."; exit 1 ;; esac
fi
-TMP="$(mktemp)"
-printf '%s\n' "$GRANT" > "$TMP"
-if ! $SUDO visudo -cf "$TMP" >/dev/null; then
+TMP="$(/usr/bin/mktemp)"
+trap '/bin/rm -f "$TMP"' EXIT
+/usr/bin/printf '%s\n' "$GRANT" > "$TMP"
+if ! "${SUDO[@]}" /usr/sbin/visudo -cf "$TMP" >/dev/null; then
echo "error: generated sudoers failed validation; not installing." >&2
- rm -f "$TMP"; exit 1
+ exit 1
fi
-$SUDO install -m 0440 -o root -g wheel "$TMP" "$SUDOERS_DST"
-rm -f "$TMP"
-$SUDO visudo -c >/dev/null && echo "✅ grant installed and sudoers parses cleanly ($SUDOERS_DST)."
+"${SUDO[@]}" /usr/bin/install -m 0440 -o root -g wheel "$TMP" "$SUDOERS_DST"
+"${SUDO[@]}" /usr/sbin/visudo -c >/dev/null && echo "✅ grant installed and sudoers parses cleanly ($SUDOERS_DST)."
echo " Toggle Sleepless from the menu bar; it will no longer need a password."
diff --git a/install.sh b/install.sh
index 3b186c8..ceb45a2 100755
--- a/install.sh
+++ b/install.sh
@@ -12,7 +12,7 @@ APP="/Applications/$APP_NAME.app"
BUNDLE_ID="com.aboudjem.Sleepless"
SUDOERS_DST="/etc/sudoers.d/sleepless-disablesleep"
LAUNCH_AGENT="$HOME/Library/LaunchAgents/$BUNDLE_ID.plist"
-USER_NAME="$(id -un)"
+USER_UID="$(id -u)"
echo "Sleepless installer"
echo "==================="
@@ -21,7 +21,7 @@ echo " 1. Build $APP_NAME.app and copy it to /Applications."
echo " 2. Install a passwordless sudo grant at $SUDOERS_DST so the app can flip"
echo " lid-close sleep without prompting. The grant (root:wheel, 0440) is EXACTLY:"
echo ""
-echo " $USER_NAME ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
+echo " #$USER_UID ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
echo ""
echo " That is the only thing it permits — turn lid-close sleep on or off. Nothing else."
echo " 3. Add a login item (~/Library/LaunchAgents/$BUNDLE_ID.plist) so it starts at login."
diff --git a/sleepless.sudoers.template b/sleepless.sudoers.template
index 2973926..d9c8b78 100644
--- a/sleepless.sudoers.template
+++ b/sleepless.sudoers.template
@@ -1,12 +1,12 @@
# Sleepless passwordless grant — template.
#
-# install.sh substitutes __USER__ with your login name ($(id -un)) and installs
+# grant.sh substitutes __UID__ with your numeric user ID ($(id -u)) and installs
# the result to /etc/sudoers.d/sleepless-disablesleep, owned root:wheel, mode 0440.
#
-# It grants ONE user the right to run, as root, EXACTLY these two fully-specified
+# It grants ONE UID the right to run, as root, EXACTLY these two fully-specified
# commands and NOTHING else. sudoers matches command arguments literally (there are
# no wildcards here), so this grant cannot be widened by appending other flags —
# `sudo pmset -a sleep 0`, `pmset restoredefaults`, etc. all fall through and demand
# a password. The only thing it lets Sleepless do without a prompt is flip lid-close
# sleep on (1) or off (0). See SECURITY.md for the full threat model.
-__USER__ ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
+#__UID__ ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
From e96cfed5a1749940ae41b169f3b85ce7e7be9295 Mon Sep 17 00:00:00 2001
From: Farnood
Date: Sun, 7 Jun 2026 14:03:57 -0400
Subject: [PATCH 2/4] Add agent-aware safety cutoffs
Co-authored-by: Cursor
---
.github/ISSUE_TEMPLATE/bug_report.yml | 2 +-
.github/ISSUE_TEMPLATE/feature_request.yml | 4 +-
.github/PULL_REQUEST_TEMPLATE.md | 2 +-
.github/workflows/ci.yml | 18 +-
.github/workflows/release.yml | 20 +-
AgentMonitor.swift | 595 ++++++
App.swift | 513 +++--
AppLogger.swift | 88 +
CHANGELOG.md | 34 +
CONTEXT.md | 24 +-
CONTRIBUTING.md | 4 +-
ConnectivityMonitor.swift | 39 +
Info.plist | 4 +-
PowerController.swift | 57 +
README.de.md | 2 +-
README.es.md | 2 +-
README.fr.md | 2 +-
README.ja.md | 2 +-
README.md | 74 +-
README.zh-CN.md | 2 +-
SECURITY.md | 43 +-
ShellRunner.swift | 54 +
build.sh | 35 +-
docs/AUDIT.md | 29 +-
docs/LAUNCH.md | 14 +-
docs/LISTINGS.md | 34 +-
docs/adr/0001-local-agent-detection.md | 2 +-
docs/index.html | 2146 +++++++++++++++-----
grant.sh | 6 +-
install.sh | 14 +-
make-icon.swift | 161 +-
reset-agent-setup.sh | 110 +
uninstall.sh | 26 +-
33 files changed, 3311 insertions(+), 851 deletions(-)
create mode 100644 AgentMonitor.swift
create mode 100644 AppLogger.swift
create mode 100644 ConnectivityMonitor.swift
create mode 100644 PowerController.swift
create mode 100644 ShellRunner.swift
create mode 100755 reset-agent-setup.sh
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index bc384f7..4a42bcb 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -21,7 +21,7 @@ body:
attributes:
label: Steps to reproduce
placeholder: |
- 1. Click the coffee cup in the menu bar
+ 1. Click the Sleepless agent in the menu bar
2. Toggle the switch on
3. Close the lid (on battery)
validations:
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 95aa0ab..88f1858 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -1,11 +1,11 @@
name: Feature request
-description: Suggest an idea for Sleepless
+description: Suggest an idea for Sleepless Agents
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
- Sleepless is deliberately small. Features that grow the privilege surface (more
+ Sleepless Agents is deliberately small. Features that grow the privilege surface (more
sudo, a helper daemon, a kext) are unlikely to land — the tight security model is a
core feature. Ideas that keep it small, native, and honest are very welcome.
- type: textarea
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index beb7ff5..2b3b4ee 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,4 +1,4 @@
-
+
## What does this change?
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 417ccb2..e18e6b5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -22,13 +22,21 @@ jobs:
swiftc --version
echo "SDK: $(xcrun --sdk macosx --show-sdk-version 2>/dev/null || echo n/a)"
- - name: Compile App.swift (zero-warning gate)
+ - name: Compile Swift sources (zero-warning gate)
run: |
set -euo pipefail
# Local + release builds target arm64-apple-macos26.0. CI compiles against the
# runner's SDK (which may predate macOS 26) purely as a clean-compile smoke test,
# so it does not force the 26.0 deployment target here.
- swiftc -O -parse-as-library -framework AppKit App.swift -o /tmp/Sleepless 2> build.log \
+ swiftc -O -parse-as-library \
+ -framework AppKit -framework ServiceManagement -framework Network \
+ AppLogger.swift \
+ ShellRunner.swift \
+ PowerController.swift \
+ AgentMonitor.swift \
+ ConnectivityMonitor.swift \
+ App.swift \
+ -o /tmp/Sleepless 2> build.log \
|| { echo "::group::swiftc output"; cat build.log; echo "::endgroup::"; exit 1; }
cat build.log
if grep -q "warning:" build.log; then
@@ -42,11 +50,11 @@ jobs:
set -euo pipefail
# Build the full bundle (conservative target so it works on the runner's SDK).
TARGET="arm64-apple-macos13.0" ./build.sh "$PWD/dist"
- ls -la "dist/Sleepless.app/Contents" "dist/Sleepless.app/Contents/MacOS"
+ ls -la "dist/Sleepless Agents.app/Contents" "dist/Sleepless Agents.app/Contents/MacOS"
- name: Upload app artifact
uses: actions/upload-artifact@v4
with:
- name: Sleepless-app
- path: dist/Sleepless.app
+ name: Sleepless-Agents-app
+ path: dist/Sleepless Agents.app
if-no-files-found: error
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 328eb02..c8f8619 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -7,17 +7,17 @@ name: Release
on:
push:
- tags: ['v*']
+ tags: ["v*"]
workflow_dispatch:
inputs:
tag:
- description: 'Tag to (re)build a release for, e.g. v1.1.0'
+ description: "Tag to (re)build a release for, e.g. v1.1.0"
required: true
permissions:
- contents: write # create the release + upload assets
- id-token: write # Sigstore OIDC, required by attest-build-provenance
- attestations: write # write the build-provenance attestation to this repo
+ contents: write # create the release + upload assets
+ id-token: write # Sigstore OIDC, required by attest-build-provenance
+ attestations: write # write the build-provenance attestation to this repo
jobs:
release:
@@ -40,20 +40,20 @@ jobs:
swiftc --version
echo "SDK: $(xcrun --sdk macosx --show-sdk-version 2>/dev/null || echo n/a)"
- - name: Build Sleepless.app
+ - name: Build Sleepless Agents.app
run: |
set -euo pipefail
# Conservative deployment target so it compiles against the runner SDK and
# runs on macOS 13+. (Local + tested target is arm64-apple-macos26.0.)
TARGET="arm64-apple-macos13.0" ./build.sh "$PWD/dist"
- codesign --verify --verbose=1 "dist/Sleepless.app"
+ codesign --verify --verbose=1 "dist/Sleepless Agents.app"
- name: Zip the app bundle
id: zip
run: |
set -euo pipefail
ASSET="Sleepless-${{ steps.ver.outputs.version }}.zip"
- ditto -c -k --keepParent "dist/Sleepless.app" "$ASSET"
+ ditto -c -k --keepParent "dist/Sleepless Agents.app" "$ASSET"
echo "asset=$ASSET" >> "$GITHUB_OUTPUT"
ls -la "$ASSET"
@@ -98,9 +98,9 @@ jobs:
ASSET="${{ steps.zip.outputs.asset }}"
if gh release view "$TAG" >/dev/null 2>&1; then
gh release upload "$TAG" "$ASSET" SHA256SUMS --clobber
- gh release edit "$TAG" --notes-file NOTES.md --title "Sleepless ${{ steps.ver.outputs.version }}"
+ gh release edit "$TAG" --notes-file NOTES.md --title "Sleepless Agents ${{ steps.ver.outputs.version }}"
else
gh release create "$TAG" "$ASSET" SHA256SUMS \
- --title "Sleepless ${{ steps.ver.outputs.version }}" \
+ --title "Sleepless Agents ${{ steps.ver.outputs.version }}" \
--notes-file NOTES.md
fi
diff --git a/AgentMonitor.swift b/AgentMonitor.swift
new file mode 100644
index 0000000..d353a7a
--- /dev/null
+++ b/AgentMonitor.swift
@@ -0,0 +1,595 @@
+import AppKit
+import Darwin
+import Foundation
+
+enum AgentID: String, CaseIterable {
+ case claude
+ case codex
+ case cursor
+
+ var displayName: String {
+ switch self {
+ case .claude: return "Claude Code"
+ case .codex: return "Codex"
+ case .cursor: return "Cursor"
+ }
+ }
+
+ var commandName: String {
+ switch self {
+ case .claude: return "claude"
+ case .codex: return "codex"
+ case .cursor: return "cursor"
+ }
+ }
+}
+
+enum AgentStatus: String {
+ case active = "Active"
+ case idle = "Idle"
+ case setupNeeded = "Setup needed"
+}
+
+struct AgentToolSnapshot {
+ let id: AgentID
+ let displayName: String
+ let status: AgentStatus
+ let detail: String
+}
+
+struct AgentSetupResult {
+ let ok: Bool
+ let message: String
+ let logURL: URL
+}
+
+final class AgentMonitor {
+ private let heartbeatFreshness: TimeInterval = 120
+ private let heartbeatScriptVersion = "4"
+ private let queue = DispatchQueue(label: "Sleepless.AgentMonitor", qos: .utility)
+ private let fileManager = FileManager.default
+ private var cachedCLIPaths: [String: String] = [:]
+ private var cachedCursorInstalled: Bool?
+
+ func snapshotsAsync(completion: @escaping ([AgentToolSnapshot]) -> Void) {
+ queue.async {
+ let snapshots = self.snapshots()
+ DispatchQueue.main.async {
+ completion(snapshots)
+ }
+ }
+ }
+
+ private func snapshots() -> [AgentToolSnapshot] {
+ let processes = processList()
+ return AgentID.allCases.compactMap { snapshot(for: $0, processes: processes) }
+ }
+
+ func installIntegration(for id: AgentID) -> AgentSetupResult {
+ AppLogger.info("agent_setup_start", ["tool": id.rawValue])
+ do {
+ let script = try writeHeartbeatHelper()
+ let installed: Bool
+ switch id {
+ case .claude:
+ installed = try installClaudeHooks(script: script)
+ case .codex:
+ installed = try installCodexHooks(script: script)
+ case .cursor:
+ installed = try installCursorHooks(script: script)
+ }
+ guard installed else {
+ let message = "Hook command was written but could not be verified in the tool config."
+ AppLogger.error("agent_setup_verify_failed", ["tool": id.rawValue])
+ return AgentSetupResult(ok: false, message: message, logURL: AppLogger.logURL)
+ }
+ let readme = heartbeatDirectory.appendingPathComponent("README.txt")
+ let text = """
+ Sleepless Agents heartbeat helper
+
+ Helper:
+ \(script.path)
+
+ Configure an app-wide hook in the agent tool to run this helper with the tool id:
+ \(script.path) \(id.rawValue)
+
+ Sleepless Agents reads only these heartbeat files to decide whether local agent work is active.
+ """
+ try text.write(to: readme, atomically: true, encoding: .utf8)
+ AppLogger.info("agent_setup_success", ["tool": id.rawValue])
+ return AgentSetupResult(ok: true, message: "\(id.displayName) detector set up.", logURL: AppLogger.logURL)
+ } catch {
+ let nsError = error as NSError
+ let message = setupErrorMessage(error)
+ AppLogger.error("agent_setup_failed", [
+ "tool": id.rawValue,
+ "domain": nsError.domain,
+ "code": String(nsError.code),
+ "reason": message
+ ])
+ NSLog("Sleepless Agents: agent integration setup failed: %@", message)
+ return AgentSetupResult(ok: false, message: message, logURL: AppLogger.logURL)
+ }
+ }
+
+ private func snapshot(for id: AgentID, processes: [ProcessSnapshot]) -> AgentToolSnapshot? {
+ switch id {
+ case .claude:
+ guard let path = resolveCLI(command: "claude", extraPaths: [
+ "\(home)/.local/bin/claude",
+ "\(home)/.claude/local/bin/claude"
+ ]) else { return nil }
+ let status = statusFromIntegration(for: id)
+ return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: path)
+
+ case .codex:
+ guard let path = resolveCLI(command: "codex", extraPaths: [
+ "\(home)/.local/bin/codex"
+ ]) else { return nil }
+ let status = statusFromIntegration(for: id)
+ return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: path)
+
+ case .cursor:
+ guard cursorInstalled() else { return nil }
+ if heartbeatIsFresh(for: id) || cursorAgentProcessMatches(processes) {
+ return AgentToolSnapshot(id: id, displayName: id.displayName, status: .active, detail: "Local agent signal")
+ }
+ let status: AgentStatus = integrationConfigured(for: id) ? .idle : .setupNeeded
+ return AgentToolSnapshot(id: id, displayName: id.displayName, status: status, detail: "Cursor app installed")
+ }
+ }
+
+ private func statusFromIntegration(for id: AgentID) -> AgentStatus {
+ guard integrationConfigured(for: id) else { return .setupNeeded }
+ return heartbeatIsFresh(for: id) ? .active : .idle
+ }
+
+ private func resolveCLI(command: String, extraPaths: [String]) -> String? {
+ if let cached = cachedCLIPaths[command] { return cached }
+
+ let candidates = [
+ "/opt/homebrew/bin/\(command)",
+ "/usr/local/bin/\(command)",
+ "/usr/bin/\(command)",
+ "/bin/\(command)"
+ ] + extraPaths
+
+ for path in candidates where isExecutable(path) && validates(commandAt: path) {
+ cachedCLIPaths[command] = path
+ return path
+ }
+
+ let shell = ShellRunner.run("/bin/zsh", ["-lc", "command -v \(command)"], timeout: 2)
+ let path = shell.out.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard shell.exit == 0, !path.isEmpty, isExecutable(path), validates(commandAt: path) else {
+ return nil
+ }
+ cachedCLIPaths[command] = path
+ return path
+ }
+
+ private func validates(commandAt path: String) -> Bool {
+ ShellRunner.run(path, ["--version"], timeout: 2).exit == 0
+ }
+
+ private func isExecutable(_ path: String) -> Bool {
+ fileManager.isExecutableFile(atPath: NSString(string: path).expandingTildeInPath)
+ }
+
+ private func cursorInstalled() -> Bool {
+ if let cachedCursorInstalled { return cachedCursorInstalled }
+ let workspace = NSWorkspace.shared
+ let installed = workspace.urlForApplication(withBundleIdentifier: "com.todesktop.230313mzl4w4u92") != nil
+ || workspace.urlForApplication(withBundleIdentifier: "co.anysphere.cursor.nightly") != nil
+ cachedCursorInstalled = installed
+ return installed
+ }
+
+ private func cursorAgentProcessMatches(_ processes: [ProcessSnapshot]) -> Bool {
+ processes.contains { proc in
+ guard proc.uid == getuid() else { return false }
+ return proc.command == "cursor-agent"
+ || proc.command == "cursor-agent-worker"
+ }
+ }
+
+ private func heartbeatIsFresh(for id: AgentID) -> Bool {
+ let url = heartbeatURL(for: id)
+ guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
+ let modified = attrs[.modificationDate] as? Date else {
+ return false
+ }
+ guard heartbeatFile(at: url, belongsTo: id) else { return false }
+ return Date().timeIntervalSince(modified) <= heartbeatFreshness
+ }
+
+ private func heartbeatFile(at url: URL, belongsTo id: AgentID) -> Bool {
+ guard let data = try? Data(contentsOf: url),
+ let text = String(data: data, encoding: .utf8) else {
+ return false
+ }
+ var fields: [String: String] = [:]
+ text.split(whereSeparator: \.isNewline).forEach { line in
+ let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false)
+ guard parts.count == 2 else { return }
+ fields[String(parts[0])] = String(parts[1])
+ }
+ guard fields["version"] == heartbeatScriptVersion,
+ fields["tool"] == id.rawValue,
+ fields["state"] == "active",
+ let timestamp = fields["time"].flatMap(TimeInterval.init) else {
+ return false
+ }
+ if id != .cursor, heartbeatOriginIsCursor(fields["process_chain"] ?? "") {
+ return false
+ }
+ return Date().timeIntervalSince1970 - timestamp <= heartbeatFreshness
+ }
+
+ private func heartbeatOriginIsCursor(_ processChain: String) -> Bool {
+ processChain
+ .lowercased()
+ .split(separator: "|")
+ .contains { part in
+ let name = part.trimmingCharacters(in: .whitespacesAndNewlines)
+ return name == "cursor" || name.hasPrefix("cursor ") || name.contains("/cursor")
+ }
+ }
+
+ private func integrationConfigured(for id: AgentID) -> Bool {
+ do {
+ let root = try readJSONObject(at: configURL(for: id))
+ guard let hooks = root["hooks"] as? [String: Any] else { return false }
+ let configured = expectedEvents(for: id, script: heartbeatDirectory.appendingPathComponent("heartbeat.sh")).allSatisfy { event in
+ guard let entries = hooks[event.name] as? [[String: Any]] else { return false }
+ return entries.contains { hookEntry($0, contains: event.command, nestedCommandSchema: usesNestedHookSchema(id)) }
+ }
+ guard configured else { return false }
+ if !heartbeatHelperIsCurrent() {
+ _ = try writeHeartbeatHelper()
+ }
+ return true
+ } catch {
+ AppLogger.error("agent_setup_structural_verify_failed", [
+ "tool": id.rawValue,
+ "reason": error.localizedDescription
+ ])
+ return false
+ }
+ }
+
+ private func installClaudeHooks(script: URL) throws -> Bool {
+ let url = configURL(for: .claude)
+ AppLogger.info("agent_setup_merge_config", ["tool": AgentID.claude.rawValue, "path": url.path])
+ let events = expectedEvents(for: .claude, script: script)
+ try mergeCommandHooks(
+ into: url,
+ events: events,
+ nestedCommandSchema: true,
+ versioned: false
+ )
+ return integrationConfigured(for: .claude)
+ }
+
+ private func installCodexHooks(script: URL) throws -> Bool {
+ let url = configURL(for: .codex)
+ AppLogger.info("agent_setup_merge_config", ["tool": AgentID.codex.rawValue, "path": url.path])
+ let events = expectedEvents(for: .codex, script: script)
+ try mergeCommandHooks(
+ into: url,
+ events: events,
+ nestedCommandSchema: true,
+ versioned: false
+ )
+ return integrationConfigured(for: .codex)
+ }
+
+ private func installCursorHooks(script: URL) throws -> Bool {
+ let url = configURL(for: .cursor)
+ AppLogger.info("agent_setup_merge_config", ["tool": AgentID.cursor.rawValue, "path": url.path])
+ let events = expectedEvents(for: .cursor, script: script)
+ try mergeCommandHooks(
+ into: url,
+ events: events,
+ nestedCommandSchema: false,
+ versioned: true
+ )
+ return integrationConfigured(for: .cursor)
+ }
+
+ private func mergeCommandHooks(into url: URL, events: [HookEvent], nestedCommandSchema: Bool, versioned: Bool) throws {
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ var root = try readJSONObject(at: url)
+ if versioned, root["version"] == nil { root["version"] = 1 }
+ var hooks: [String: Any]
+ if let existing = root["hooks"] {
+ if let typed = existing as? [String: Any] {
+ hooks = typed
+ } else {
+ let backup = backupInvalidConfig(at: url)
+ AppLogger.error("agent_setup_invalid_hooks_shape_replaced", ["path": url.path, "backup": backup.path])
+ root = versioned ? ["version": 1] : [:]
+ hooks = [:]
+ }
+ } else {
+ hooks = [:]
+ }
+ hooks = pruneSleeplessHooks(from: hooks, nestedCommandSchema: nestedCommandSchema)
+
+ for event in events {
+ var entries: [[String: Any]]
+ if let existing = hooks[event.name] {
+ if let typed = existing as? [[String: Any]] {
+ entries = typed
+ } else {
+ let backup = backupInvalidConfig(at: url)
+ AppLogger.error("agent_setup_invalid_event_shape_replaced", [
+ "path": url.path,
+ "backup": backup.path,
+ "event": event.name
+ ])
+ root = versioned ? ["version": 1] : [:]
+ hooks = [:]
+ entries = []
+ }
+ } else {
+ entries = []
+ }
+ let alreadyInstalled = entries.contains { hookEntry($0, contains: event.command, nestedCommandSchema: nestedCommandSchema) }
+ guard !alreadyInstalled else { continue }
+
+ if nestedCommandSchema {
+ var entry: [String: Any] = [
+ "hooks": [
+ [
+ "type": "command",
+ "command": event.command
+ ]
+ ]
+ ]
+ if let matcher = event.matcher { entry["matcher"] = matcher }
+ entries.append(entry)
+ } else {
+ entries.append(["command": event.command])
+ }
+ hooks[event.name] = entries
+ }
+
+ root["hooks"] = hooks
+ try writeJSONObject(root, to: url)
+ }
+
+ private func configURL(for id: AgentID) -> URL {
+ switch id {
+ case .claude:
+ return homeURL.appendingPathComponent(".claude/settings.json")
+ case .codex:
+ return homeURL.appendingPathComponent(".codex/hooks.json")
+ case .cursor:
+ return homeURL.appendingPathComponent(".cursor/hooks.json")
+ }
+ }
+
+ private func usesNestedHookSchema(_ id: AgentID) -> Bool {
+ id == .claude || id == .codex
+ }
+
+ private func expectedEvents(for id: AgentID, script: URL) -> [HookEvent] {
+ switch id {
+ case .claude:
+ return [
+ HookEvent(name: "UserPromptSubmit", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: nil, state: "active"),
+ HookEvent(name: "PreToolUse", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: ".*", state: "active"),
+ HookEvent(name: "PostToolUse", command: heartbeatCommand(script: script, tool: .claude, state: "active"), matcher: ".*", state: "active"),
+ HookEvent(name: "Stop", command: heartbeatCommand(script: script, tool: .claude, state: "stop"), matcher: nil, state: "stop")
+ ]
+ case .codex:
+ return [
+ HookEvent(name: "UserPromptSubmit", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: nil, state: "active"),
+ HookEvent(name: "PreToolUse", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: ".*", state: "active"),
+ HookEvent(name: "PostToolUse", command: heartbeatCommand(script: script, tool: .codex, state: "active"), matcher: ".*", state: "active"),
+ HookEvent(name: "Stop", command: heartbeatCommand(script: script, tool: .codex, state: "stop"), matcher: nil, state: "stop")
+ ]
+ case .cursor:
+ return [
+ HookEvent(name: "beforeSubmitPrompt", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"),
+ HookEvent(name: "preToolUse", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"),
+ HookEvent(name: "postToolUse", command: heartbeatCommand(script: script, tool: .cursor, state: "active"), matcher: nil, state: "active"),
+ HookEvent(name: "stop", command: heartbeatCommand(script: script, tool: .cursor, state: "stop"), matcher: nil, state: "stop")
+ ]
+ }
+ }
+
+ private func hookEntry(_ entry: [String: Any], contains command: String, nestedCommandSchema: Bool) -> Bool {
+ if nestedCommandSchema {
+ guard let hooks = entry["hooks"] as? [[String: Any]] else { return false }
+ return hooks.contains { ($0["command"] as? String) == command }
+ }
+ return (entry["command"] as? String) == command
+ }
+
+ private func pruneSleeplessHooks(from hooks: [String: Any], nestedCommandSchema: Bool) -> [String: Any] {
+ var pruned = hooks
+ for (event, value) in hooks {
+ guard let entries = value as? [[String: Any]] else { continue }
+ let kept = entries.compactMap { pruneSleeplessHookEntry($0, nestedCommandSchema: nestedCommandSchema) }
+ if kept.isEmpty {
+ pruned.removeValue(forKey: event)
+ } else {
+ pruned[event] = kept
+ }
+ }
+ return pruned
+ }
+
+ private func pruneSleeplessHookEntry(_ entry: [String: Any], nestedCommandSchema: Bool) -> [String: Any]? {
+ if !nestedCommandSchema {
+ return commandOwnedBySleepless(entry["command"]) ? nil : entry
+ }
+
+ guard let hooks = entry["hooks"] as? [[String: Any]] else { return entry }
+ let keptHooks = hooks.filter { !commandOwnedBySleepless($0["command"]) }
+ guard keptHooks.count != hooks.count else { return entry }
+ guard !keptHooks.isEmpty else { return nil }
+ var updated = entry
+ updated["hooks"] = keptHooks
+ return updated
+ }
+
+ private func commandOwnedBySleepless(_ command: Any?) -> Bool {
+ guard let command = command as? String else { return false }
+ return command.contains(".sleepless/agents/heartbeat.sh")
+ }
+
+ private func readJSONObject(at url: URL) throws -> [String: Any] {
+ guard fileManager.fileExists(atPath: url.path) else { return [:] }
+ let data = try Data(contentsOf: url)
+ guard !data.isEmpty else { return [:] }
+ let parsed: Any
+ do {
+ parsed = try JSONSerialization.jsonObject(with: data)
+ } catch {
+ let backup = backupInvalidConfig(at: url)
+ AppLogger.error("agent_setup_invalid_json_replaced", ["path": url.path, "backup": backup.path])
+ return [:]
+ }
+ guard let object = parsed as? [String: Any] else {
+ let backup = backupInvalidConfig(at: url)
+ AppLogger.error("agent_setup_invalid_top_level_replaced", ["path": url.path, "backup": backup.path])
+ return [:]
+ }
+ return object
+ }
+
+ private func backupInvalidConfig(at url: URL) -> URL {
+ let stamp = ISO8601DateFormatter().string(from: Date())
+ .replacingOccurrences(of: ":", with: "-")
+ let backup = url.deletingLastPathComponent()
+ .appendingPathComponent(url.lastPathComponent + ".sleepless-backup-\(stamp)")
+ try? fileManager.copyItem(at: url, to: backup)
+ return backup
+ }
+
+ private func writeJSONObject(_ object: [String: Any], to url: URL) throws {
+ let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])
+ try data.write(to: url, options: .atomic)
+ try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
+ }
+
+ private func heartbeatCommand(for id: AgentID, state: String) -> String {
+ heartbeatCommand(script: heartbeatDirectory.appendingPathComponent("heartbeat.sh"), tool: id, state: state)
+ }
+
+ private func heartbeatCommand(script: URL, tool: AgentID, state: String) -> String {
+ "\(shellQuoted(script.path)) \(tool.rawValue) \(state)"
+ }
+
+ private func shellQuoted(_ value: String) -> String {
+ "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
+ }
+
+ private func processList() -> [ProcessSnapshot] {
+ let out = ShellRunner.capture("/bin/ps", ["-axo", "pid=,uid=,comm="], timeout: 2)
+ return out.split(separator: "\n").compactMap { line in
+ let parts = line.split(separator: " ", maxSplits: 2, omittingEmptySubsequences: true)
+ guard parts.count == 3, let pid = Int32(parts[0]), let uid = uid_t(String(parts[1])) else { return nil }
+ return ProcessSnapshot(
+ pid: pid,
+ uid: uid,
+ executablePath: String(parts[2])
+ )
+ }
+ }
+
+ private var home: String { homeURL.path }
+ private var homeURL: URL { fileManager.homeDirectoryForCurrentUser }
+ private var heartbeatDirectory: URL { homeURL.appendingPathComponent(".sleepless/agents", isDirectory: true) }
+ private func heartbeatURL(for id: AgentID) -> URL { heartbeatDirectory.appendingPathComponent("\(id.rawValue).heartbeat") }
+
+ private func writeHeartbeatHelper() throws -> URL {
+ try fileManager.createDirectory(at: heartbeatDirectory, withIntermediateDirectories: true)
+ let script = heartbeatDirectory.appendingPathComponent("heartbeat.sh")
+ AppLogger.info("agent_setup_write_helper", ["path": script.path, "version": heartbeatScriptVersion])
+ try heartbeatHelperBody.write(to: script, atomically: true, encoding: .utf8)
+ try fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: script.path)
+ AgentID.allCases.forEach { try? fileManager.removeItem(at: heartbeatURL(for: $0)) }
+ return script
+ }
+
+ private func heartbeatHelperIsCurrent() -> Bool {
+ let script = heartbeatDirectory.appendingPathComponent("heartbeat.sh")
+ guard fileManager.isExecutableFile(atPath: script.path),
+ let data = try? Data(contentsOf: script),
+ let text = String(data: data, encoding: .utf8) else {
+ return false
+ }
+ return text.contains("SLEEPLESS_HEARTBEAT_VERSION=\(heartbeatScriptVersion)")
+ }
+
+ private var heartbeatHelperBody: String {
+ """
+ #!/bin/zsh
+ set -eu
+ SLEEPLESS_HEARTBEAT_VERSION=\(heartbeatScriptVersion)
+ tool="${1:-unknown}"
+ state="${2:-active}"
+ case "$tool" in
+ claude|codex|cursor) ;;
+ *) exit 0 ;;
+ esac
+ dir="$HOME/.sleepless/agents"
+ mkdir -p "$dir"
+ process_chain=""
+ pid="$$"
+ while [ -n "$pid" ] && [ "$pid" != "0" ]; do
+ comm="$(/bin/ps -o comm= -p "$pid" 2>/dev/null || true)"
+ [ -n "$comm" ] && process_chain="${process_chain:+$process_chain|}$comm"
+ pid="$(/bin/ps -o ppid= -p "$pid" 2>/dev/null | /usr/bin/tr -d ' ' || true)"
+ done
+ write_heartbeat() {
+ tmp="$dir/$tool.heartbeat.tmp"
+ {
+ /bin/echo "version=$SLEEPLESS_HEARTBEAT_VERSION"
+ /bin/echo "tool=$tool"
+ /bin/echo "state=$state"
+ /bin/echo "time=$(/bin/date +%s)"
+ /bin/echo "process_chain=$process_chain"
+ } > "$tmp"
+ /bin/mv "$tmp" "$dir/$tool.heartbeat"
+ }
+ case "$state" in
+ stop) state="stop"; write_heartbeat ;;
+ *) state="active"; write_heartbeat ;;
+ esac
+ """
+ }
+
+ private func setupErrorMessage(_ error: Error) -> String {
+ let nsError = error as NSError
+ switch nsError.code {
+ case NSFileWriteNoPermissionError, NSFileReadNoPermissionError:
+ return "Permission denied while writing the agent hook config."
+ case NSFileWriteFileExistsError:
+ return "A file already exists where a setup directory is needed."
+ default:
+ return error.localizedDescription
+ }
+ }
+}
+
+private struct ProcessSnapshot {
+ let pid: Int32
+ let uid: uid_t
+ let executablePath: String
+
+ var command: String {
+ URL(fileURLWithPath: executablePath).lastPathComponent
+ }
+}
+
+private struct HookEvent {
+ let name: String
+ let command: String
+ let matcher: String?
+ let state: String
+}
diff --git a/App.swift b/App.swift
index e606276..512935b 100644
--- a/App.swift
+++ b/App.swift
@@ -11,16 +11,16 @@
// disablesleep is runtime-only and resets to 0 on reboot, and that reset is a
// deliberate safety feature; the app does NOT auto re-arm.
//
-// UI: clicking the menu-bar coffee cup opens a small native popover with an NSSwitch
-// toggle (the System-Settings control), a state caption, an auto-off timer, the
-// battery-floor slider, a Launch-at-login switch, and Quit. The menu-bar glyph also
-// shows state at a glance.
+// UI: clicking the menu-bar agent glyph opens a small native popover with an NSSwitch
+// toggle (the System-Settings control), a state caption, auto-off controls, monitored
+// agent status, the battery-floor slider, a Launch-at-login switch, and Quit. The
+// menu-bar glyph also shows state at a glance.
//
-// The coffee-cup metaphor is literal: an EMPTY cup means the Mac sleeps normally, a
-// FULL cup means it is being kept awake (caffeinated), and a full cup with a small
-// dot means it is awake on battery with the auto-off safety net live.
+// The menu-bar mark is deliberately simple: an outline agent means the Mac sleeps
+// normally, a filled agent means it is being kept awake, and a filled agent with a
+// small dot means it is awake on battery with the auto-off safety net live.
//
-// Three small, fail-safe features layer on top, none of which adds a daemon or
+// Several fail-safe features layer on top, none of which adds a daemon or
// persists OS state (so "reboot resets it" still holds):
// 1. Auto-off timer (1h / 2h) — a one-shot in-memory Timer that flips sleep back
// on when it fires. Dies on quit; nothing survives a reboot.
@@ -29,10 +29,12 @@
// re-enable disablesleep on its own.
// 3. Low-Power-Mode auto-off — on battery, if Low Power Mode is on, Sleepless
// turns itself off. Same shape as the battery floor, evaluated on the same tick.
+// 4. Agent/internet auto-off — opt-in safety cutoffs with a grace period; they only
+// turn Sleepless off and never re-arm keep-awake.
//
// Build (mirrors Nexus.app): Command Line Tools `swiftc`, NO Xcode project.
// swiftc -O -parse-as-library -target arm64-apple-macos26.0 -framework AppKit \
-// -framework ServiceManagement
+// -framework ServiceManagement -framework Network ...
// File MUST be named App.swift and compiled -parse-as-library so the
// @main enum + @MainActor static main() entry is Swift-6 isolation-safe.
import AppKit
@@ -40,25 +42,29 @@ import Darwin
import ServiceManagement
// MARK: - Tunables
-private let pollInterval: TimeInterval = 60
+private let pollInterval: TimeInterval = 30
+private let visibleAgentRefreshInterval: TimeInterval = 2
+private let cutoffGraceInterval: TimeInterval = 120
// Battery-floor config (user-adjustable via the popover slider; persisted in UserDefaults).
private let floorKey = "batteryFloorPercent"
+private let agentAutoOffKey = "agentAutoOffEnabled"
+private let internetAutoOffKey = "internetAutoOffEnabled"
private let floorDefault = 15
private let floorMin = 5
private let floorMax = 50
+private let appDisplayName = "Sleepless Agents"
private let sudoersDropInPath = "/etc/sudoers.d/sleepless-disablesleep"
private let sudoersCommandGrant = "ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1"
-// MARK: - Menu-bar coffee glyph (native SF Symbols, MONOCHROME template — state by SHAPE)
+// MARK: - Menu-bar agent glyph (native SF Symbols, MONOCHROME template — state by SHAPE)
// macOS convention: a menu-bar extra is a template image (no colour) so it adapts to light/dark
// bars and inverts on highlight. State is read from the SILHOUETTE, not colour. The old
// empty-vs-filled cups looked near-identical at 16 px, so we switch the silhouette dramatically
-// with steam (a hot cup = awake):
-// OFF (sleeps normally) = cup.and.saucer cup resting on its saucer, NO steam (cold/asleep)
-// ON (kept awake, on power) = cup.and.heat.waves.fill hot cup with rising steam (awake)
-// ARMED (kept awake, on battery) = cup.and.heat.waves.fill + a small dot (awake, safety net live)
-// The no-steam → steam change reads instantly even at 16 px; the armed dot is the only extra
-// mark. All template (monochrome) — SF Symbols only, no hand-drawn paths.
+// with an agent/robot silhouette:
+// OFF (sleeps normally) = robot outline
+// ON (kept awake, on power) = filled robot
+// ARMED (kept awake, on battery) = filled robot + a small dot (auto-off safety net live)
+// All template (monochrome); if the robot symbol is unavailable, the coffee-cup glyph is used.
enum SleepGlyph {
case off
case on
@@ -67,10 +73,13 @@ enum SleepGlyph {
private func makeCupGlyph(_ glyph: SleepGlyph) -> NSImage {
let cfg = NSImage.SymbolConfiguration(pointSize: 15, weight: .regular).applying(.init(scale: .medium))
- let name = (glyph == .off) ? "cup.and.saucer" : "cup.and.heat.waves.fill"
- let base = NSImage(systemSymbolName: name, accessibilityDescription: "Sleepless")?
+ let name = (glyph == .off) ? "robot" : "robot.fill"
+ let fallback = (glyph == .off) ? "cup.and.saucer" : "cup.and.heat.waves.fill"
+ let base = NSImage(systemSymbolName: name, accessibilityDescription: appDisplayName)?
.withSymbolConfiguration(cfg)
- ?? NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: "Sleepless")
+ ?? NSImage(systemSymbolName: fallback, accessibilityDescription: appDisplayName)?
+ .withSymbolConfiguration(cfg)
+ ?? NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: appDisplayName)
?? NSImage()
guard glyph == .armed else {
@@ -142,6 +151,9 @@ private final class CardView: NSView {
@MainActor
final class AppDelegate: NSObject, NSApplicationDelegate {
+ private let power = PowerController()
+ private let agentMonitor = AgentMonitor()
+ private let connectivityMonitor = ConnectivityMonitor()
private var statusItem: NSStatusItem!
private var timer: Timer?
private let onGlyph = makeCupGlyph(.on)
@@ -158,11 +170,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var floorSlider: NSSlider!
private var autoOffControl: NSSegmentedControl!
private var countdownLabel: NSTextField!
+ private var internetSwitch: NSSwitch!
+ private var internetStatusLabel: NSTextField!
+ private var agentAutoOffSwitch: NSSwitch!
+ private var agentSummaryLabel: NSTextField!
+ private var agentEmptyLabel: NSTextField!
+ private var agentRows: [AgentID: (name: NSTextField, status: NSTextField, setup: NSButton)] = [:]
private var loginSwitch: NSSwitch!
private var clickMonitor: Any?
private var batteryFloorPercent = floorDefault
+ private var internetAutoOffEnabled = false
+ private var agentAutoOffEnabled = false
private var isOn = false
private var userForcedOn = false // user deliberately turned it on; honor over the Low Power Mode auto-off (the hard battery floor still wins)
+ private var lastAgentSnapshots: [AgentToolSnapshot] = []
+ private var lastInternetReachable = true
+ private var noAgentsSince: Date?
+ private var noInternetSince: Date?
+ private var agentStatusTicker: Timer?
+ private var agentRefreshInFlight = false
+ private var agentRefreshPending = false
+ private var pendingAgentRefreshCompletions: [() -> Void] = []
// Auto-off timer (in-memory; dies on quit, never survives a reboot)
private var autoOffMinutes = 0 // 0 = none (stay on until off), 60, or 120
@@ -170,12 +198,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var countdownTicker: Timer? // 1 Hz label refresh, only while the popover is open
private var timerEndDate: Date?
- private let popoverWidth: CGFloat = 320
- private let popoverHeight: CGFloat = 432
+ private let popoverWidth: CGFloat = 360
+ private let popoverHeight: CGFloat = 632
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
batteryFloorPercent = min(max((UserDefaults.standard.object(forKey: floorKey) as? Int) ?? floorDefault, floorMin), floorMax)
+ internetAutoOffEnabled = UserDefaults.standard.bool(forKey: internetAutoOffKey)
+ agentAutoOffEnabled = UserDefaults.standard.bool(forKey: agentAutoOffKey)
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.image = offGlyph
@@ -207,7 +237,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
root.blendingMode = .behindWindow
root.state = .followsWindowActiveState
- // Header: small coffee mark + "Sleepless" (quiet system glyph, not a branded logo).
+ // Header: small agent mark + app name (quiet system glyph, not a branded logo).
// The mark tints to the brand violet while the Mac is kept awake.
let mark = NSImageView(frame: NSRect(x: pad, y: 14, width: 18, height: 18))
let headerCup = makeCupGlyph(.on); headerCup.isTemplate = true
@@ -215,7 +245,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
mark.contentTintColor = .labelColor
root.addSubview(mark)
headerMark = mark
- let title = makeLabel("Sleepless", font: .systemFont(ofSize: 14, weight: .semibold), color: .labelColor)
+ let title = makeLabel(appDisplayName, font: .systemFont(ofSize: 14, weight: .semibold), color: .labelColor)
title.frame = NSRect(x: pad + 24, y: 14, width: contentW - 24, height: 20)
root.addSubview(title)
@@ -251,10 +281,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
g1.addSubview(captionLabel)
// GROUP 2 — auto-off timer (label + segmented [Off | 1h | 2h] + countdown)
- let g2y = g1y + g1h + 12, g2h: CGFloat = 78
+ let g2y = g1y + g1h + 10, g2h: CGFloat = 70
let g2 = makeCard(NSRect(x: pad, y: g2y, width: contentW, height: g2h))
let timerLabel = makeLabel("Auto-off timer", font: .systemFont(ofSize: 13), color: .labelColor)
- timerLabel.frame = NSRect(x: ci, y: ci + 3, width: 110, height: 22)
+ timerLabel.frame = NSRect(x: ci, y: ci, width: 110, height: 22)
g2.addSubview(timerLabel)
autoOffControl = NSSegmentedControl(labels: ["Off", "1h", "2h"],
trackingMode: .selectOne,
@@ -265,56 +295,106 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
autoOffControl.sizeToFit()
let segSize = autoOffControl.frame.size
let segW = segSize.width > 0 ? segSize.width : 150
- autoOffControl.frame = NSRect(x: contentW - ci - segW, y: ci, width: segW, height: max(segSize.height, 24))
+ autoOffControl.frame = NSRect(x: contentW - ci - segW, y: ci - 1, width: segW, height: max(segSize.height, 24))
g2.addSubview(autoOffControl)
countdownLabel = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
- countdownLabel.frame = NSRect(x: ci, y: ci + 36, width: cw, height: 16)
+ countdownLabel.frame = NSRect(x: ci, y: ci + 32, width: cw, height: 16)
g2.addSubview(countdownLabel)
- // GROUP 3 — battery-floor (label + value + slider + min/max hints)
- let g3y = g2y + g2h + 12, g3h: CGFloat = 92
+ // GROUP 3 — agents (only installed/detectable tools are shown)
+ let g3y = g2y + g2h + 10, g3h: CGFloat = 134
let g3 = makeCard(NSRect(x: pad, y: g3y, width: contentW, height: g3h))
+ let agentsLabel = makeLabel("Agents", font: .systemFont(ofSize: 13), color: .labelColor)
+ agentsLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22)
+ g3.addSubview(agentsLabel)
+ agentAutoOffSwitch = NSSwitch()
+ agentAutoOffSwitch.target = self
+ agentAutoOffSwitch.action = #selector(agentAutoOffToggled(_:))
+ agentAutoOffSwitch.state = agentAutoOffEnabled ? .on : .off
+ agentAutoOffSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH)
+ g3.addSubview(agentAutoOffSwitch)
+ agentSummaryLabel = makeLabel("Auto-off when no agents are running", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
+ agentSummaryLabel.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 17)
+ g3.addSubview(agentSummaryLabel)
+ agentEmptyLabel = makeLabel("No supported agent tools found", font: .systemFont(ofSize: 12), color: .tertiaryLabelColor)
+ agentEmptyLabel.frame = NSRect(x: ci, y: ci + 54, width: cw, height: 17)
+ g3.addSubview(agentEmptyLabel)
+ for (idx, id) in AgentID.allCases.enumerated() {
+ let y = ci + 52 + CGFloat(idx * 23)
+ let name = makeLabel(id.displayName, font: .systemFont(ofSize: 12), color: .labelColor)
+ name.frame = NSRect(x: ci, y: y, width: 112, height: 18)
+ let status = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
+ status.alignment = .right
+ status.frame = NSRect(x: ci + 112, y: y, width: cw - 112 - 66, height: 18)
+ let setup = NSButton(title: "Set Up", target: self, action: #selector(setupAgentIntegration(_:)))
+ setup.tag = idx
+ setup.controlSize = .small
+ setup.bezelStyle = .rounded
+ setup.frame = NSRect(x: contentW - ci - 58, y: y - 2, width: 58, height: 22)
+ g3.addSubview(name); g3.addSubview(status); g3.addSubview(setup)
+ agentRows[id] = (name, status, setup)
+ }
+
+ // GROUP 4 — internet auto-off
+ let g4y = g3y + g3h + 10, g4h: CGFloat = 58
+ let g4 = makeCard(NSRect(x: pad, y: g4y, width: contentW, height: g4h))
+ let internetLabel = makeLabel("Auto-off at no internet", font: .systemFont(ofSize: 13), color: .labelColor)
+ internetLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22)
+ g4.addSubview(internetLabel)
+ internetSwitch = NSSwitch()
+ internetSwitch.target = self
+ internetSwitch.action = #selector(internetAutoOffToggled(_:))
+ internetSwitch.state = internetAutoOffEnabled ? .on : .off
+ internetSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH)
+ g4.addSubview(internetSwitch)
+ internetStatusLabel = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
+ internetStatusLabel.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 16)
+ g4.addSubview(internetStatusLabel)
+
+ // GROUP 5 — battery-floor (label + value + slider + min/max hints)
+ let g5y = g4y + g4h + 10, g5h: CGFloat = 86
+ let g5 = makeCard(NSRect(x: pad, y: g5y, width: contentW, height: g5h))
let floorLabel = makeLabel("Auto-off at low battery", font: .systemFont(ofSize: 13), color: .labelColor)
floorLabel.frame = NSRect(x: ci, y: ci, width: cw - 54, height: 18)
- g3.addSubview(floorLabel)
+ g5.addSubview(floorLabel)
floorValueLabel = makeLabel("\(batteryFloorPercent)%", font: .systemFont(ofSize: 13, weight: .semibold), color: .secondaryLabelColor)
floorValueLabel.alignment = .right
floorValueLabel.frame = NSRect(x: contentW - ci - 54, y: ci, width: 54, height: 18)
- g3.addSubview(floorValueLabel)
+ g5.addSubview(floorValueLabel)
floorSlider = NSSlider(value: Double(batteryFloorPercent), minValue: Double(floorMin), maxValue: Double(floorMax),
target: self, action: #selector(floorSliderChanged(_:)))
- floorSlider.isContinuous = true // live update while dragging
+ floorSlider.isContinuous = true
floorSlider.controlSize = .regular
- floorSlider.frame = NSRect(x: ci, y: ci + 26, width: cw, height: 20)
- g3.addSubview(floorSlider)
+ floorSlider.frame = NSRect(x: ci, y: ci + 24, width: cw, height: 20)
+ g5.addSubview(floorSlider)
let minHint = makeLabel("\(floorMin)%", font: .systemFont(ofSize: 10), color: .tertiaryLabelColor)
- minHint.frame = NSRect(x: ci, y: ci + 50, width: 34, height: 13)
- g3.addSubview(minHint)
+ minHint.frame = NSRect(x: ci, y: ci + 48, width: 34, height: 13)
+ g5.addSubview(minHint)
let maxHint = makeLabel("\(floorMax)%", font: .systemFont(ofSize: 10), color: .tertiaryLabelColor)
maxHint.alignment = .right
- maxHint.frame = NSRect(x: contentW - ci - 34, y: ci + 50, width: 34, height: 13)
- g3.addSubview(maxHint)
+ maxHint.frame = NSRect(x: contentW - ci - 34, y: ci + 48, width: 34, height: 13)
+ g5.addSubview(maxHint)
- // GROUP 4 — launch at login (off by default; never auto-enables sleep prevention)
- let g4y = g3y + g3h + 12, g4h: CGFloat = 46
- let g4 = makeCard(NSRect(x: pad, y: g4y, width: contentW, height: g4h))
+ // GROUP 6 — launch at login (off by default; never auto-enables sleep prevention)
+ let g6y = g5y + g5h + 10, g6h: CGFloat = 42
+ let g6 = makeCard(NSRect(x: pad, y: g6y, width: contentW, height: g6h))
let loginLabel = makeLabel("Launch at login", font: .systemFont(ofSize: 13), color: .labelColor)
- loginLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22)
- g4.addSubview(loginLabel)
+ loginLabel.frame = NSRect(x: ci, y: 10, width: cw - swW - 8, height: 22)
+ g6.addSubview(loginLabel)
loginSwitch = NSSwitch()
loginSwitch.target = self
loginSwitch.action = #selector(loginToggled(_:))
loginSwitch.state = loginItemEnabled() ? .on : .off
- loginSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH)
- g4.addSubview(loginSwitch)
+ loginSwitch.frame = NSRect(x: contentW - ci - swW, y: 10 + (22 - swH) / 2, width: swW, height: swH)
+ g6.addSubview(loginSwitch)
// Footer — Quit (separated by space, not a hairline)
- let quit = NSButton(title: "Quit Sleepless", target: self, action: #selector(quit))
+ let quit = NSButton(title: "Quit \(appDisplayName)", target: self, action: #selector(quit))
quit.controlSize = .regular
quit.bezelStyle = .rounded
quit.sizeToFit()
let qs = quit.frame.size
- quit.frame = NSRect(x: W - pad - qs.width, y: g4y + g4h + 12, width: qs.width, height: qs.height)
+ quit.frame = NSRect(x: W - pad - qs.width, y: g6y + g6h + 10, width: qs.width, height: qs.height)
root.addSubview(quit)
let vc = NSViewController()
@@ -339,12 +419,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func openPopover() {
refresh() // sync switch/caption to TRUE state before showing
+ refreshAgentStatus()
loginSwitch?.state = loginItemEnabled() ? .on : .off
guard let button = statusItem.button else { return }
NSApp.activate(ignoringOtherApps: true)
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
if keepAwakeTimer != nil { startCountdownTicker() }
+ startAgentStatusTicker()
updateCountdownLabel()
// Close when the user clicks anywhere outside the app (status bar, another app, desktop).
clickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
@@ -355,6 +437,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func closePopover() {
popover.performClose(nil)
countdownTicker?.invalidate(); countdownTicker = nil // stop the 1 Hz label refresh (keep-awake timer keeps running)
+ agentStatusTicker?.invalidate(); agentStatusTicker = nil
if let monitor = clickMonitor { NSEvent.removeMonitor(monitor); clickMonitor = nil }
}
@@ -400,7 +483,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let intro = NSAlert()
intro.alertStyle = .informational
intro.messageText = "Enable keeping your Mac awake"
- intro.informativeText = "Sleepless flips a protected macOS setting (pmset disablesleep), so it needs your permission once. macOS will ask you to authenticate (Touch ID or your password). After that the switch works instantly, with no more prompts."
+ intro.informativeText = "\(appDisplayName) flips a protected macOS setting (pmset disablesleep), so it needs your permission once. macOS will ask you to authenticate (Touch ID or your password). After that the switch works instantly, with no more prompts."
intro.addButton(withTitle: "Enable")
intro.addButton(withTitle: "Not now")
NSApp.activate(ignoringOtherApps: true)
@@ -469,7 +552,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
b.layer?.add(pulse, forKey: "statePulse")
}
- @objc private func poll() { refresh() }
+ @objc private func poll() {
+ refreshAgentStatus()
+ connectivityMonitor.checkNow { [weak self] reachable in
+ guard let self else { return }
+ self.lastInternetReachable = reachable
+ self.renderInternetSection()
+ self.refresh()
+ }
+ }
// MARK: - Auto-off timer (Feature 1)
@objc private func autoOffChanged(_ sender: NSSegmentedControl) {
@@ -509,7 +600,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
autoOffMinutes = 0
autoOffControl?.selectedSegment = 0
applyUI(on: readSleepDisabled())
- notify("Auto-off timer ended. Sleepless turned off.")
+ notify("Auto-off timer ended. \(appDisplayName) turned off.")
}
private func startCountdownTicker() {
@@ -529,6 +620,146 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
countdownLabel?.stringValue = "Auto-off in \(t)"
}
+ // MARK: - Agent + internet cutoffs
+ @objc private func agentAutoOffToggled(_ sender: NSSwitch) {
+ if sender.state == .on {
+ refreshAgentStatus { [weak self] in
+ guard let self else { return }
+ let healthyCount = self.lastAgentSnapshots.filter { $0.status != .setupNeeded }.count
+ if self.lastAgentSnapshots.isEmpty {
+ self.agentAutoOffEnabled = false
+ sender.state = .off
+ UserDefaults.standard.set(false, forKey: agentAutoOffKey)
+ self.notify("No supported agent tools found.")
+ } else if healthyCount == 0 {
+ self.agentAutoOffEnabled = false
+ sender.state = .off
+ UserDefaults.standard.set(false, forKey: agentAutoOffKey)
+ self.notify("Set up an agent detector before enabling agent auto-off.")
+ } else {
+ self.agentAutoOffEnabled = true
+ UserDefaults.standard.set(true, forKey: agentAutoOffKey)
+ }
+ self.renderAgentSection()
+ }
+ return
+ }
+ agentAutoOffEnabled = false
+ UserDefaults.standard.set(false, forKey: agentAutoOffKey)
+ noAgentsSince = nil
+ renderAgentSection()
+ }
+
+ @objc private func internetAutoOffToggled(_ sender: NSSwitch) {
+ internetAutoOffEnabled = sender.state == .on
+ UserDefaults.standard.set(internetAutoOffEnabled, forKey: internetAutoOffKey)
+ if !internetAutoOffEnabled { noInternetSince = nil }
+ renderInternetSection()
+ }
+
+ @objc private func setupAgentIntegration(_ sender: NSButton) {
+ guard sender.tag >= 0, sender.tag < AgentID.allCases.count else { return }
+ let id = AgentID.allCases[sender.tag]
+ let result = agentMonitor.installIntegration(for: id)
+ if result.ok {
+ notify(result.message)
+ let alert = NSAlert()
+ alert.alertStyle = .informational
+ alert.messageText = "\(id.displayName) detector set up"
+ alert.informativeText = "\(appDisplayName) installed an app-wide hook for \(id.displayName). The row should now show Idle, and it will show Active only while the hook is producing fresh activity heartbeats."
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ } else {
+ notify("Couldn't set up \(id.displayName). Details were logged.")
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = "Couldn't set up \(id.displayName)"
+ alert.informativeText = "\(result.message)\n\nDebug log:\n\(result.logURL.path)"
+ alert.addButton(withTitle: "OK")
+ alert.runModal()
+ }
+ refreshAgentStatus()
+ }
+
+ private func startAgentStatusTicker() {
+ agentStatusTicker?.invalidate()
+ agentStatusTicker = Timer.scheduledTimer(timeInterval: visibleAgentRefreshInterval, target: self,
+ selector: #selector(agentStatusTick), userInfo: nil, repeats: true)
+ }
+
+ @objc private func agentStatusTick() { refreshAgentStatus() }
+
+ private func refreshAgentStatus(completion: (() -> Void)? = nil) {
+ guard !agentRefreshInFlight else {
+ agentRefreshPending = true
+ if let completion { pendingAgentRefreshCompletions.append(completion) }
+ return
+ }
+ agentRefreshInFlight = true
+ agentMonitor.snapshotsAsync { [weak self] snapshots in
+ guard let self else { return }
+ self.agentRefreshInFlight = false
+ self.lastAgentSnapshots = snapshots
+ self.renderAgentSection()
+ completion?()
+ if self.agentRefreshPending {
+ let completions = self.pendingAgentRefreshCompletions
+ self.pendingAgentRefreshCompletions = []
+ self.agentRefreshPending = false
+ self.refreshAgentStatus {
+ completions.forEach { $0() }
+ }
+ }
+ }
+ }
+
+ private func renderAgentSection() {
+ agentAutoOffSwitch?.state = agentAutoOffEnabled ? .on : .off
+ let activeCount = lastAgentSnapshots.filter { $0.status == .active }.count
+ let healthyCount = lastAgentSnapshots.filter { $0.status != .setupNeeded }.count
+ if lastAgentSnapshots.isEmpty {
+ agentSummaryLabel?.stringValue = "Auto-off when no agents are running"
+ agentEmptyLabel?.isHidden = false
+ agentAutoOffSwitch?.isEnabled = false
+ } else {
+ agentEmptyLabel?.isHidden = true
+ agentAutoOffSwitch?.isEnabled = true
+ if activeCount > 0 {
+ agentSummaryLabel?.stringValue = "\(activeCount) active agent\(activeCount == 1 ? "" : "s") detected"
+ } else if healthyCount == 0 {
+ agentSummaryLabel?.stringValue = "Set up a detector before auto-off can act"
+ } else {
+ agentSummaryLabel?.stringValue = "No active agents detected"
+ }
+ }
+
+ for id in AgentID.allCases {
+ guard let row = agentRows[id] else { continue }
+ guard let snapshot = lastAgentSnapshots.first(where: { $0.id == id }) else {
+ row.name.isHidden = true
+ row.status.isHidden = true
+ row.setup.isHidden = true
+ continue
+ }
+ row.name.isHidden = false
+ row.status.isHidden = false
+ let setupNeeded = snapshot.status == .setupNeeded
+ row.setup.isHidden = !setupNeeded
+ let statusRightEdge = setupNeeded ? row.setup.frame.minX - 8 : row.setup.frame.maxX
+ row.status.frame.size.width = max(0, statusRightEdge - row.status.frame.minX)
+ row.status.stringValue = snapshot.status.rawValue
+ row.status.textColor = snapshot.status == .active ? brandAccentSoft : .secondaryLabelColor
+ }
+ }
+
+ private func renderInternetSection() {
+ internetSwitch?.state = internetAutoOffEnabled ? .on : .off
+ internetStatusLabel?.stringValue = lastInternetReachable
+ ? "Internet reachable"
+ : "Internet not reachable"
+ internetStatusLabel?.textColor = lastInternetReachable ? .secondaryLabelColor : .systemOrange
+ }
+
// MARK: - Launch at login (Feature 2) — OFF by default; never re-enables sleep prevention
@objc private func loginToggled(_ sender: NSSwitch) {
do {
@@ -568,24 +799,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
button.toolTip = on
? (armed
- ? "Sleepless: on (battery). Auto-off at \(batteryFloorPercent)% or in Low Power Mode."
- : "Sleepless: on. Stays awake with the lid closed.")
- : "Sleepless: off. Sleeps normally."
+ ? "\(appDisplayName): on (battery). Auto-off at \(batteryFloorPercent)% or in Low Power Mode."
+ : "\(appDisplayName): on. Stays awake with the lid closed.")
+ : "\(appDisplayName): off. Sleeps normally."
}
toggleSwitch?.state = on ? .on : .off
// Brand-violet accent communicates the privileged "awake" state at a glance.
mainCard?.active = on
headerMark?.contentTintColor = on ? brandAccentSoft : .labelColor
renderText()
+ renderAgentSection()
+ renderInternetSection()
updateCountdownLabel()
}
// Update text labels only (no pmset subprocess; safe to call on every slider tick).
private func renderText() {
floorValueLabel?.stringValue = "\(batteryFloorPercent)%"
- captionLabel?.stringValue = isOn
- ? "Stays awake when the lid is closed. Turns off at \(batteryFloorPercent)% battery or in Low Power Mode."
- : "Sleeps normally when you close the lid."
+ if isOn {
+ var cutoffs = ["\(batteryFloorPercent)% battery", "Low Power Mode"]
+ if internetAutoOffEnabled { cutoffs.append("no internet") }
+ if agentAutoOffEnabled { cutoffs.append("no agents") }
+ captionLabel?.stringValue = "Stays awake with the lid closed. Turns off at " + cutoffs.joined(separator: ", ") + "."
+ } else {
+ captionLabel?.stringValue = "Sleeps normally when you close the lid."
+ }
}
@objc private func floorSliderChanged(_ sender: NSSlider) {
@@ -597,105 +835,90 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
renderText()
}
- // Result of the privileged keep-awake toggle, based on sudo's REAL exit status — not on a
- // second, independent state read. `.ok` = the command ran; `.grantMissing` = the passwordless
- // sudoers grant isn't installed (sudo -n refused), the one case that warrants setup; `.failed`
- // = any other error. Using sudo's own result (instead of re-reading SleepDisabled) is the fix:
- // a safety net flipping sleep back on must never look like "permission missing" and re-prompt.
- private enum ToggleResult: Equatable { case ok, grantMissing, failed(String) }
-
@discardableResult
private func setDisableSleep(_ on: Bool) -> ToggleResult {
- // sudo -n: never prompt (GUI app has no TTY). The exact argument vector matches the
- // NOPASSWD sudoers grant, so this runs without a password.
- let (exit, _, err) = runPrivileged(["-n", "/usr/bin/pmset", "-a", "disablesleep", on ? "1" : "0"])
- let result: ToggleResult
- if exit == 0 {
- result = .ok
- } else if err.range(of: "a password is required", options: .caseInsensitive) != nil
- || err.range(of: "not allowed", options: .caseInsensitive) != nil
- || err.range(of: "may not run", options: .caseInsensitive) != nil {
- result = .grantMissing // grant absent/removed -> sudo -n refused to run passwordless
- } else {
- result = .failed(err.isEmpty ? "exit \(exit)" : err.trimmingCharacters(in: .whitespacesAndNewlines))
- }
- return result
- }
-
- // Run a privileged command via sudo, capturing exit status + stderr (which the generic
- // runCapture discards). stdin is /dev/null so a GUI process with no controlling TTY can
- // never block on a prompt. This is what lets the app KNOW whether its own toggle worked.
- private func runPrivileged(_ args: [String]) -> (exit: Int32, out: String, err: String) {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
- process.arguments = args
- var env = ProcessInfo.processInfo.environment
- env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
- env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path
- process.environment = env
- let outPipe = Pipe(), errPipe = Pipe()
- process.standardOutput = outPipe
- process.standardError = errPipe
- process.standardInput = FileHandle.nullDevice
- do { try process.run() }
- catch {
- NSLog("Sleepless: failed to launch sudo: %@", error.localizedDescription)
- return (-1, "", "launch failed: \(error.localizedDescription)")
- }
- let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
- let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
- return (process.terminationStatus,
- String(data: outData, encoding: .utf8) ?? "",
- String(data: errData, encoding: .utf8) ?? "")
+ power.setDisableSleep(on)
}
// MARK: - Battery + Low-Power-Mode safety nets (silent; no extra UI) — Feature 3
private func enforceSafetyNets() {
let (onBattery, discharging, percent) = batteryStatus()
- guard onBattery, discharging else { return }
- // Hard battery floor ALWAYS wins, even over a deliberate turn-on: never drain to empty.
- if percent <= batteryFloorPercent {
- setDisableSleep(false); userForcedOn = false
- applyUI(on: readSleepDisabled())
- notify("Battery low (\(percent)%). Sleepless turned off.")
+ if onBattery, discharging {
+ // Hard battery floor ALWAYS wins, even over a deliberate turn-on: never drain to empty.
+ if percent <= batteryFloorPercent {
+ turnOffFromSafetyNet("Battery low (\(percent)%). \(appDisplayName) turned off.")
+ userForcedOn = false
+ return
+ }
+ // Low Power Mode auto-off, UNLESS the user deliberately chose to keep awake this session.
+ if ProcessInfo.processInfo.isLowPowerModeEnabled && !userForcedOn {
+ turnOffFromSafetyNet("Low Power Mode on. \(appDisplayName) turned off.")
+ return
+ }
+ }
+
+ enforceInternetCutoff()
+ enforceAgentCutoff()
+ }
+
+ private func enforceInternetCutoff() {
+ guard internetAutoOffEnabled else { noInternetSince = nil; return }
+ if lastInternetReachable {
+ noInternetSince = nil
+ return
+ }
+ let since = noInternetSince ?? Date()
+ noInternetSince = since
+ if Date().timeIntervalSince(since) >= cutoffGraceInterval {
+ noInternetSince = nil
+ turnOffFromSafetyNet("No internet connection. \(appDisplayName) turned off.")
+ }
+ }
+
+ private func enforceAgentCutoff() {
+ guard agentAutoOffEnabled else { noAgentsSince = nil; return }
+ if lastAgentSnapshots.isEmpty {
+ agentAutoOffEnabled = false
+ UserDefaults.standard.set(false, forKey: agentAutoOffKey)
+ noAgentsSince = nil
+ renderAgentSection()
+ notify("No supported agent tools found. Agent auto-off was disabled.")
+ return
+ }
+ let healthy = lastAgentSnapshots.filter { $0.status != .setupNeeded }
+ guard !healthy.isEmpty else { noAgentsSince = nil; return }
+ if healthy.contains(where: { $0.status == .active }) {
+ noAgentsSince = nil
return
}
- // Low Power Mode auto-off, UNLESS the user deliberately chose to keep awake this session.
- if ProcessInfo.processInfo.isLowPowerModeEnabled && !userForcedOn {
- setDisableSleep(false)
- applyUI(on: readSleepDisabled())
- notify("Low Power Mode on. Sleepless turned off.")
+ let since = noAgentsSince ?? Date()
+ noAgentsSince = since
+ if Date().timeIntervalSince(since) >= cutoffGraceInterval {
+ noAgentsSince = nil
+ turnOffFromSafetyNet("No agents running. \(appDisplayName) turned off.")
}
}
+ private func turnOffFromSafetyNet(_ message: String) {
+ setDisableSleep(false)
+ applyUI(on: readSleepDisabled())
+ notify(message)
+ }
+
// MARK: - Readers (no root needed)
private func readSleepDisabled() -> Bool {
- let out = runCapture("/usr/bin/pmset", ["-g"])
- for line in out.split(whereSeparator: { $0 == "\n" }) {
- if line.range(of: "SleepDisabled", options: .caseInsensitive) != nil {
- let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
- if let last = toks.last { return last == "1" }
- }
- }
- return false // line absent -> OFF
+ power.readSleepDisabled()
}
private func batteryStatus() -> (onBattery: Bool, discharging: Bool, percent: Int) {
- let out = runCapture("/usr/bin/pmset", ["-g", "batt"])
- let onBattery = out.contains("Battery Power")
- let discharging = out.range(of: "discharging", options: .caseInsensitive) != nil
- var percent = 100
- for tok in out.split(whereSeparator: { " \t\n;".contains($0) }) {
- if tok.hasSuffix("%"), let v = Int(tok.dropLast()) { percent = v; break }
- }
- return (onBattery, discharging, percent)
+ let status = power.batteryStatus()
+ return (status.onBattery, status.discharging, status.percent)
}
// MARK: - Notification (mirrors Nexus' osascript approach)
private func notify(_ message: String) {
- let script = "display notification \(appleScriptStringLiteral(message)) with title \(appleScriptStringLiteral("Sleepless")) sound name \(appleScriptStringLiteral("Tink"))"
- _ = runCapture("/usr/bin/osascript", ["-e", script])
+ let script = "display notification \(appleScriptStringLiteral(message)) with title \(appleScriptStringLiteral(appDisplayName)) sound name \(appleScriptStringLiteral("Tink"))"
+ _ = ShellRunner.capture("/usr/bin/osascript", ["-e", script])
}
private func appleScriptStringLiteral(_ s: String) -> String {
@@ -707,26 +930,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return "\"\(escaped)\""
}
- // MARK: - Process runner (explicit PATH/HOME; captures stdout)
- @discardableResult
- private func runCapture(_ launchPath: String, _ args: [String]) -> String {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: launchPath)
- process.arguments = args
- var env = ProcessInfo.processInfo.environment
- env["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin"
- env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path
- process.environment = env
- let pipe = Pipe()
- process.standardOutput = pipe
- process.standardError = Pipe()
- do { try process.run() }
- catch { NSLog("Sleepless: failed to launch %@: %@", launchPath, error.localizedDescription); return "" }
- let data = pipe.fileHandleForReading.readDataToEndOfFile()
- process.waitUntilExit()
- return String(data: data, encoding: .utf8) ?? ""
- }
-
@objc private func quit() { NSApp.terminate(nil) }
}
diff --git a/AppLogger.swift b/AppLogger.swift
new file mode 100644
index 0000000..b77696d
--- /dev/null
+++ b/AppLogger.swift
@@ -0,0 +1,88 @@
+import Foundation
+
+enum AppLogger {
+ private static let subsystem = "SleeplessAgents"
+ private static let maxBytes = 256 * 1024
+ private static let queue = DispatchQueue(label: "SleeplessAgents.AppLogger", qos: .utility)
+ private static let fileManager = FileManager.default
+
+ static var logURL: URL {
+ let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first
+ ?? fileManager.homeDirectoryForCurrentUser.appendingPathComponent("Library/Caches", isDirectory: true)
+ return base
+ .appendingPathComponent("com.aboudjem.Sleepless", isDirectory: true)
+ .appendingPathComponent("setup-diagnostics.jsonl")
+ }
+
+ static func info(_ event: String, _ fields: [String: String] = [:]) {
+ write(level: "INFO", event: event, fields: fields)
+ }
+
+ static func error(_ event: String, _ fields: [String: String] = [:]) {
+ write(level: "ERROR", event: event, fields: fields)
+ }
+
+ private static func write(level: String, event: String, fields: [String: String]) {
+ queue.async {
+ var object: [String: Any] = [
+ "ts": ISO8601DateFormatter().string(from: Date()),
+ "level": level.lowercased(),
+ "event": event,
+ "pid": Int(ProcessInfo.processInfo.processIdentifier)
+ ]
+ if let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String {
+ object["appVersion"] = version
+ }
+ if let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String {
+ object["build"] = build
+ }
+ for (key, value) in fields {
+ object[key] = redact(value)
+ }
+ guard JSONSerialization.isValidJSONObject(object),
+ let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]) else {
+ NSLog("%@: failed to encode log event %@", subsystem, event)
+ return
+ }
+ let encoded = String(decoding: data, as: UTF8.self)
+ let line = String(encoded.prefix(4096)) + "\n"
+ append(line)
+ }
+ }
+
+ private static func append(_ line: String) {
+ let url = logURL
+ do {
+ try fileManager.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ rotateIfNeeded(url)
+ let data = Data(line.utf8)
+ if fileManager.fileExists(atPath: url.path) {
+ let handle = try FileHandle(forWritingTo: url)
+ try handle.seekToEnd()
+ try handle.write(contentsOf: data)
+ try handle.close()
+ } else {
+ try data.write(to: url, options: .atomic)
+ try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
+ }
+ } catch {
+ NSLog("%@: failed to write log: %@", subsystem, error.localizedDescription)
+ }
+ }
+
+ private static func rotateIfNeeded(_ url: URL) {
+ guard let attrs = try? fileManager.attributesOfItem(atPath: url.path),
+ let size = attrs[.size] as? NSNumber,
+ size.intValue > maxBytes else {
+ return
+ }
+ let archive = url.deletingLastPathComponent().appendingPathComponent("setup-diagnostics.jsonl.1")
+ try? fileManager.removeItem(at: archive)
+ try? fileManager.moveItem(at: url, to: archive)
+ }
+
+ private static func redact(_ value: String) -> String {
+ let home = fileManager.homeDirectoryForCurrentUser.path
+ return value.replacingOccurrences(of: home, with: "~")
+ }
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af0109d..972d6d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Agent-aware auto-off: Sleepless can show local Claude Code, Codex, and Cursor agent
+ status and, when enabled, turn itself off after no monitored agents are active for a
+ grace period.
+- No-internet auto-off: an opt-in cutoff that turns Sleepless off after sustained public
+ internet reachability loss.
+- Local agent-detection documentation and an ADR that rules out UI scraping, Screen
+ Recording, broad filesystem searches, and cloud-only monitoring.
+
+### Changed
+
+- Split the native app into focused Swift files for power control, command execution,
+ agent monitoring, and connectivity monitoring.
+- Updated the app identity toward an agent/robot having coffee while keeping the
+ menu-bar mark monochrome and template-safe.
+
## [1.2.7] - 2026-06-03
### Changed
+
- Redesigned the menu-bar icon so the three states are unmistakable at a glance. It
stays a monochrome template icon (adapts to light/dark menu bars and inverts on
highlight, the macOS convention), but now changes shape instead of just filling in:
@@ -21,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.6] - 2026-06-03
### Fixed
+
- The switch could still show a password prompt or look like it "wouldn't stay on" in
edge cases, because the app judged success by re-reading the sleep state with a second
`pmset` call right after toggling, rather than trusting whether the privileged command
@@ -32,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
a permission problem.
### Changed
+
- The privileged toggle now captures its real exit status and stderr (previously
discarded) and runs with its input detached from any terminal, so a GUI launch can
never stall on a prompt and the app always knows whether the toggle worked.
@@ -39,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.5] - 2026-06-02
### Fixed
+
- In Low Power Mode the switch would not stay on and the password prompt kept
reappearing. Cause: the Low Power Mode safety net turned it back off, and the app
misread that off-state as a missing permission and re-prompted. The app now checks
@@ -46,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
so a safety-net turn-off never triggers a setup prompt.
### Changed
+
- A deliberate turn-on now overrides the Low Power Mode auto-off for that session, so
the switch stays on when you explicitly ask for it. The hard battery floor (default
15%) still always cuts in to protect against draining the Mac flat.
@@ -53,6 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.4] - 2026-06-02
### Fixed
+
- The switch kept asking for the password even after the permission was correctly
installed. The app pre-checked the grant with `sudo -l`, but listing sudo privileges
itself needs authentication even when a NOPASSWD rule is present, so the check always
@@ -62,6 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.3] - 2026-06-02
### Fixed
+
- The one-time setup installed the grant for the wrong user. Under the native auth
sheet, grant.sh runs as root with `SUDO_USER` unset, so it wrote the rule for `root`
instead of the real user, which meant the switch never engaged and kept re-asking for
@@ -72,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.2] - 2026-06-02
### Changed
+
- Setting up the one-time permission no longer means running anything in Terminal.
The first time you flip the switch on, Sleepless installs the scoped grant itself
through a single native macOS authentication sheet (Touch ID or your password).
@@ -82,18 +107,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.2.1] - 2026-06-02
### Fixed
+
- The keep-awake switch no longer snaps back with no explanation when the one-time
passwordless grant is missing. If turning it on cannot engage `disablesleep`,
Sleepless now shows a short alert that names the cause and offers to copy the
`grant.sh` command or open Terminal, so the toggle is never a silent dead end.
### Added
+
- A brief pulse on the menu-bar cup whenever the state changes, so the empty-cup to
full-cup transition is easy to notice.
## [1.2.0] - 2026-06-02
### Changed
+
- New look. Sleepless now wears a vibrant 2026 "Liquid Glass" design in an indigo,
violet, and fuchsia palette, across the app icon, the menu-bar popover, the landing
page, and all brand art. The coffee-cup metaphor and the three menu-bar states stay
@@ -105,11 +133,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
with the same white cup, plus a soft steam wisp at larger sizes.
### Added
+
- A richer badge row and a security and version trust strip (build-provenance
attestation, SHA-256 checksums, no telemetry, MIT, CI, platform) on the landing page
and across all six READMEs.
### Unchanged
+
- Same single AppKit file, no daemon, no kernel extension, no Dock icon. `disablesleep`
still resets on reboot, the scoped `/etc/sudoers.d` grant is identical, and every
verified fact, FAQ answer, and comparison result is unchanged. Only the visual layer
@@ -118,6 +148,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.1.0] - 2026-06-02
### Added
+
- Auto-off timer. Keep the Mac awake for 1 hour or 2 hours with a live countdown,
then Sleepless turns itself back off. The timer is in-memory only, so quitting or
rebooting clears it.
@@ -127,6 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
off, the same safety shape as the battery floor.
### Changed
+
- New coffee-cup icon. The menu-bar glyph and the app icon are now a coffee cup
instead of a moon: an empty cup means normal sleep, a full cup means kept awake, and
a full cup with a small dot means awake on battery with the auto-off net live. The old
@@ -135,12 +167,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
launch-at-login toggle, with the state caption noting both auto-off conditions.
### Unchanged
+
- Still one AppKit file, no daemon, no kernel extension, no Dock icon. `disablesleep`
still resets on reboot, and the tightly scoped `/etc/sudoers.d` grant is the same.
## [1.0.0] - 2026-06-01
### Added
+
- Menu-bar toggle that keeps a Mac awake with the lid closed, on battery, with no
external display, via the undocumented `pmset disablesleep` setting.
- Passwordless toggling through a tightly scoped `/etc/sudoers.d` grant limited to the
diff --git a/CONTEXT.md b/CONTEXT.md
index 2d71648..36845e7 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -2,21 +2,21 @@
## Glossary
-- **Keep-awake state**: The user-controlled state where Sleepless keeps the Mac awake when it would otherwise sleep.
+- **Keep-awake state**: The user-controlled state where Sleepless Agents keeps the Mac awake when it would otherwise sleep.
- **Agent auto-off**: A safety cutoff that may end the keep-awake state when no monitored agents are active. It does not start or re-enable the keep-awake state.
-- **Active agent**: A monitored coding-agent session that Sleepless can detect through local CLI, process, or session signals without reading another app's UI. A live session counts as active even when it is waiting for input or approval. Detection may use at most one optional, non-screen macOS permission when it provides reliable non-UI signals.
-- **Locally observable agent**: Agent work with a local process, worker, session signal, or integration heartbeat. Cloud-only agent work without a local signal is outside Sleepless' monitoring contract.
-- **Monitored agent tool**: An installed coding-agent tool for which Sleepless has a reliable local detector. Tools without reliable detectors are not shown in agent status and do not affect agent auto-off.
-- **Installed agent tool**: A coding-agent tool discovered through bounded, tool-specific signals such as a validated CLI executable, a known app bundle identifier, or an official local integration. Sleepless does not use exhaustive filesystem searches to discover tools.
-- **Agent integration**: An app-wide, opt-in integration such as a hook or heartbeat that helps Sleepless detect active agents without UI scraping. Project-by-project integrations are too high-friction to be required, and app-wide integrations are required only when default local detection is not reliable enough.
-- **Healthy agent detection**: The state where Sleepless has the required local signals and any required permission to evaluate at least one monitored agent tool. Agent auto-off starts inactive, asks for required permission only when the user enables it, and stays enabled while at least one monitored tool is available.
+- **Active agent**: A monitored coding-agent session that Sleepless Agents can detect through local CLI, process, or session signals without reading another app's UI. A live session counts as active even when it is waiting for input or approval. Detection may use at most one optional, non-screen macOS permission when it provides reliable non-UI signals.
+- **Locally observable agent**: Agent work with a local process, worker, session signal, or integration heartbeat. Cloud-only agent work without a local signal is outside Sleepless Agents' monitoring contract.
+- **Monitored agent tool**: An installed coding-agent tool for which Sleepless Agents has a reliable local detector. Tools without reliable detectors are not shown in agent status and do not affect agent auto-off.
+- **Installed agent tool**: A coding-agent tool discovered through bounded, tool-specific signals such as a validated CLI executable, a known app bundle identifier, or an official local integration. Sleepless Agents does not use exhaustive filesystem searches to discover tools.
+- **Agent integration**: An app-wide, opt-in integration such as a hook or heartbeat that helps Sleepless Agents detect active agents without UI scraping. Project-by-project integrations are too high-friction to be required, and app-wide integrations are required only when default local detection is not reliable enough.
+- **Healthy agent detection**: The state where Sleepless Agents has the required local signals and any required permission to evaluate at least one monitored agent tool. Agent auto-off starts inactive, asks for required permission only when the user enables it, and stays enabled while at least one monitored tool is available.
- **Agent status**: The user-visible state of a monitored agent tool, shown as Active, Idle, or Setup needed.
- **Agent setup**: A per-tool action in the controls popover that sets up required app-wide integrations or prompts for required permission. Detailed explanation lives in documentation rather than a setup wizard.
-- **No agent tools available**: The state where Sleepless finds no monitored agent tools. The controls popover explains that no supported agent tools were found, and agent auto-off is unavailable.
-- **Controls popover**: The single menu-bar popover where Sleepless exposes keep-awake controls, safety cutoffs, and monitored agent status.
+- **No agent tools available**: The state where Sleepless Agents finds no monitored agent tools. The controls popover explains that no supported agent tools were found, and agent auto-off is unavailable.
+- **Controls popover**: The single menu-bar popover where Sleepless Agents exposes keep-awake controls, safety cutoffs, and monitored agent status.
- **Agent coffee logo**: The robot-with-coffee brand mark used for app and marketing identity. The menu bar uses a simplified monochrome template glyph derived from the same idea.
-- **Native lightweight app**: Sleepless remains a small native macOS menu-bar app, but implementation may be split across focused Swift files when features are too broad for a single source file.
+- **Native lightweight app**: Sleepless Agents remains a small native macOS menu-bar app, but implementation may be split across focused Swift files when features are too broad for a single source file.
- **No internet connection**: A sustained inability to reach the public internet across consecutive checks, determined from macOS network path status plus a lightweight HTTPS reachability probe.
-- **Realtime agent status**: A visible status that updates every three seconds while the user is looking at the controls.
+- **Realtime agent status**: A visible status that updates every two seconds while the user is looking at the controls.
- **Auto-off grace period**: A two-minute delay before an agent or internet safety cutoff acts, used to avoid turning off during transient network drops, tool restarts, or session handoffs.
-- **Safety cutoff**: Any enabled condition that may end the keep-awake state. Safety cutoffs combine independently; any one of them may turn Sleepless off. New cutoffs default off until the user enables them.
+- **Safety cutoff**: Any enabled condition that may end the keep-awake state. Safety cutoffs combine independently; any one of them may turn Sleepless Agents off. New cutoffs default off until the user enables them.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 652b4e1..b4d4696 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -23,8 +23,8 @@ No Xcode project — just the Command Line Tools:
```sh
git clone https://github.com/Aboudjem/Sleepless.git
cd Sleepless
-./build.sh # builds ./build/Sleepless.app, ad-hoc signed
-open build/Sleepless.app
+./build.sh # builds ./build/Sleepless Agents.app, ad-hoc signed
+open "build/Sleepless Agents.app"
```
`./install.sh` additionally installs the passwordless grant + login item (it prints exactly
diff --git a/ConnectivityMonitor.swift b/ConnectivityMonitor.swift
new file mode 100644
index 0000000..c3cbb61
--- /dev/null
+++ b/ConnectivityMonitor.swift
@@ -0,0 +1,39 @@
+import Foundation
+import Network
+
+final class ConnectivityMonitor {
+ private let monitor = NWPathMonitor()
+ private let queue = DispatchQueue(label: "Sleepless.ConnectivityMonitor")
+ private var pathIsSatisfied = true
+ private let probeURL = URL(string: "https://www.apple.com/library/test/success.html")!
+
+ init() {
+ monitor.pathUpdateHandler = { [weak self] path in
+ self?.queue.async {
+ self?.pathIsSatisfied = path.status == .satisfied
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ func checkNow(completion: @escaping (Bool) -> Void) {
+ queue.async {
+ let pathOk = self.pathIsSatisfied
+ guard pathOk else {
+ DispatchQueue.main.async { completion(false) }
+ return
+ }
+
+ var request = URLRequest(url: self.probeURL)
+ request.httpMethod = "HEAD"
+ request.timeoutInterval = 5
+ request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
+
+ URLSession.shared.dataTask(with: request) { _, response, error in
+ let code = (response as? HTTPURLResponse)?.statusCode
+ let ok = error == nil && code.map { 200..<400 ~= $0 } == true
+ DispatchQueue.main.async { completion(ok) }
+ }.resume()
+ }
+ }
+}
diff --git a/Info.plist b/Info.plist
index 5bf5d26..048b2bb 100644
--- a/Info.plist
+++ b/Info.plist
@@ -4,8 +4,8 @@
CFBundleExecutableSleeplessCFBundleIdentifiercom.aboudjem.Sleepless
- CFBundleNameSleepless
- CFBundleDisplayNameSleepless
+ CFBundleNameSleepless Agents
+ CFBundleDisplayNameSleepless AgentsCFBundlePackageTypeAPPLCFBundleIconFileSleeplessCFBundleShortVersionString1.2.7
diff --git a/PowerController.swift b/PowerController.swift
new file mode 100644
index 0000000..accf0e1
--- /dev/null
+++ b/PowerController.swift
@@ -0,0 +1,57 @@
+import Foundation
+
+struct BatteryStatus {
+ let onBattery: Bool
+ let discharging: Bool
+ let percent: Int
+}
+
+enum ToggleResult: Equatable {
+ case ok
+ case grantMissing
+ case failed(String)
+}
+
+@MainActor
+final class PowerController {
+ func readSleepDisabled() -> Bool {
+ let out = ShellRunner.capture("/usr/bin/pmset", ["-g"])
+ for line in out.split(whereSeparator: { $0 == "\n" }) {
+ if line.range(of: "SleepDisabled", options: .caseInsensitive) != nil {
+ let toks = line.split(whereSeparator: { $0 == " " || $0 == "\t" })
+ if let last = toks.last { return last == "1" }
+ }
+ }
+ return false
+ }
+
+ func batteryStatus() -> BatteryStatus {
+ let out = ShellRunner.capture("/usr/bin/pmset", ["-g", "batt"])
+ let onBattery = out.contains("Battery Power")
+ let discharging = out.range(of: "discharging", options: .caseInsensitive) != nil
+ var percent = 100
+ for tok in out.split(whereSeparator: { " \t\n;".contains($0) }) {
+ if tok.hasSuffix("%"), let v = Int(tok.dropLast()) {
+ percent = v
+ break
+ }
+ }
+ return BatteryStatus(onBattery: onBattery, discharging: discharging, percent: percent)
+ }
+
+ @discardableResult
+ func setDisableSleep(_ on: Bool) -> ToggleResult {
+ let res = ShellRunner.run(
+ "/usr/bin/sudo",
+ ["-n", "/usr/bin/pmset", "-a", "disablesleep", on ? "1" : "0"],
+ stdinNull: true
+ )
+ if res.exit == 0 { return .ok }
+ if res.err.range(of: "a password is required", options: .caseInsensitive) != nil
+ || res.err.range(of: "not allowed", options: .caseInsensitive) != nil
+ || res.err.range(of: "may not run", options: .caseInsensitive) != nil {
+ return .grantMissing
+ }
+ return .failed(res.err.isEmpty ? "exit \(res.exit)" : res.err.trimmingCharacters(in: .whitespacesAndNewlines))
+ }
+}
diff --git a/README.de.md b/README.de.md
index 13bfd0d..f1610da 100644
--- a/README.de.md
+++ b/README.de.md
@@ -47,7 +47,7 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
| Weitere Wege | |
diff --git a/README.es.md b/README.es.md
index a440042..dce8acc 100644
--- a/README.es.md
+++ b/README.es.md
@@ -47,7 +47,7 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
| Otras formas | |
diff --git a/README.fr.md b/README.fr.md
index 6e93f21..799913a 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -47,7 +47,7 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
| Autres méthodes | |
diff --git a/README.ja.md b/README.ja.md
index 2db7226..7cc2fdc 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -47,7 +47,7 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
| その他の方法 | |
diff --git a/README.md b/README.md
index 972c743..fbba3b1 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -47,40 +47,42 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
-| Other ways | |
-|---|---|
-| **Download** | Grab the [latest release](https://github.com/Aboudjem/Sleepless/releases/latest), unzip to `/Applications`, then approve it in **System Settings → Privacy & Security → Open Anyway** (it is ad-hoc signed). |
-| **Build from source** | `git clone https://github.com/Aboudjem/Sleepless.git && cd Sleepless && ./install.sh` (no Gatekeeper prompt). |
+| Other ways | |
+| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| **Download** | Grab the [latest release](https://github.com/Aboudjem/Sleepless/releases/latest), unzip to `/Applications`, then approve it in **System Settings → Privacy & Security → Open Anyway** (it is ad-hoc signed). |
+| **Build from source** | `git clone https://github.com/Aboudjem/Sleepless.git && cd Sleepless && ./install.sh` (no Gatekeeper prompt). |
-Then click the cup in the menu bar, flip the switch, and close the lid.
+Then click Sleepless Agents in the menu bar, flip the switch, and close the lid.
## Features
-| | | |
-|---|---|---|
-| ☕ | **One switch** | Click the menu-bar cup, flip the toggle. |
-| ⏲️ | **Auto-off timer** | 1h or 2h with a live countdown, then off. |
-| 🔋 | **Battery floor** | Auto-off at 5–50% on battery (default 15%). |
-| 🪫 | **Low Power Mode** | Steps aside when LPM is on, on battery. |
-| 🖥️ | **No dongle** | Lid closed, on battery. No monitor, no HDMI plug. |
-| 🚀 | **Launch at login** | Optional, off by default, always starts idle. |
-| 🪶 | **Tiny + native** | One AppKit file. No Dock icon, daemon, or kext. |
-
-**Menu-bar glyph:** empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live).
-
-## Sleepless vs the alternatives
-
-| | **Sleepless** | Amphetamine | KeepingYouAwake | `caffeinate` |
-|---|:---:|:---:|:---:|:---:|
-| Awake, lid closed, no monitor | ✅ ¹ | ⚠️ ² | ❌ ³ | ❌ |
-| On battery | ✅ | ✅ | ✅ lid open | ⚠️ ⁴ |
-| Auto-off timer | ✅ | ✅ | ✅ | ❌ |
-| Auto-off on low battery | ✅ | ✅ | ✅ | ❌ |
-| Open source | ✅ MIT | ❌ App Store | ✅ MIT | Apple |
-| Cost | Free | Free | Free | Free |
+| | | |
+| --- | ------------------------ | ------------------------------------------------------------------------------------------------ |
+| 🤖 | **One switch** | Click the menu-bar agent, flip the toggle. |
+| ⏲️ | **Auto-off timer** | 1h or 2h with a live countdown, then off. |
+| 🔋 | **Battery floor** | Auto-off at 5–50% on battery (default 15%). |
+| 🤖 | **Agent-aware auto-off** | Watches local Claude Code, Codex, and Cursor signals, then turns off when no agents are running. |
+| 📡 | **No-internet auto-off** | Turns off after sustained public-internet reachability loss. |
+| 🪫 | **Low Power Mode** | Steps aside when LPM is on, on battery. |
+| 🖥️ | **No dongle** | Lid closed, on battery. No monitor, no HDMI plug. |
+| 🚀 | **Launch at login** | Optional, off by default, always starts idle. |
+| 🪶 | **Tiny + native** | Small AppKit app. No Dock icon, daemon, kext, UI scraping, or Screen Recording. |
+
+**Menu-bar glyph:** outline agent = off · filled agent = awake · filled agent + dot = awake on battery (auto-off live).
+
+## Sleepless Agents vs the alternatives
+
+| | **Sleepless Agents** | Amphetamine | KeepingYouAwake | `caffeinate` |
+| ----------------------------- | :------------------: | :----------: | :-------------: | :----------: |
+| Awake, lid closed, no monitor | ✅ ¹ | ⚠️ ² | ❌ ³ | ❌ |
+| On battery | ✅ | ✅ | ✅ lid open | ⚠️ ⁴ |
+| Auto-off timer | ✅ | ✅ | ✅ | ❌ |
+| Auto-off on low battery | ✅ | ✅ | ✅ | ❌ |
+| Open source | ✅ MIT | ❌ App Store | ✅ MIT | Apple |
+| Cost | Free | Free | Free | Free |
As of 2026-06. ¹ Uses `pmset disablesleep` and reads the flag back; behavior is hardware/macOS-version dependent. ² Documents closed-display mode but is widely reported to fail on Apple Silicon on power-source changes ([AE #28](https://github.com/x74353/Amphetamine-Enhancer/issues/28)); the app is closed source. ³ Can't do lid-closed by design, it wraps `caffeinate` ([#66](https://github.com/newmarcel/KeepingYouAwake/issues/66)). ⁴ `caffeinate -i` runs on battery; `-s` is AC-only.
@@ -96,16 +98,18 @@ Then click the cup in the menu bar, flip the switch, and close the lid.
## How it works
-Sleepless toggles `pmset disablesleep` (the kernel's `SleepDisabled` flag), reads it back so the menu bar never lies, and reverts it at your battery floor, in Low Power Mode, when the timer ends, or on reboot. A GUI app can't type a password, so the installer adds a scoped sudoers rule for **exactly two commands**:
+Sleepless Agents toggles `pmset disablesleep` (the kernel's `SleepDisabled` flag), reads it back so the menu bar never lies, and reverts it at your battery floor, in Low Power Mode, when the timer ends, when an enabled agent/internet cutoff fires, or on reboot. A GUI app can't type a password, so the installer adds a scoped sudoers rule for **exactly two commands**:
```
# ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
```
- **Can't be widened.** sudoers matches arguments literally, no wildcards.
-- **Nothing to hijack.** No daemon, no helper script, no shell. It calls `/usr/bin/pmset` directly.
+- **Nothing privileged to hijack.** No daemon and no privileged helper script. The ongoing keep-awake toggle calls `/usr/bin/pmset` directly with an argv array.
- **Always reversible.** Reboot, the floor, the timer, or `./uninstall.sh` (which proves the grant is gone).
+Agent monitoring is local-only: bounded CLI/app detection, user-owned processes, and optional heartbeat hooks for tools that need stronger signals. Sleepless does not scrape app windows, request Screen Recording, or monitor vendor cloud agents with no local signal. Internet auto-off uses macOS network path status plus a lightweight HTTPS reachability probe, and both new cutoffs default off until you enable them.
+
Verify a download, no Apple account needed:
```sh
@@ -121,36 +125,42 @@ Full threat model, the App Store verdict, and the audit guide: [SECURITY.md](SEC
Does pmset disablesleep still work on Apple Silicon (M1/M2/M3)?
Yes. `pmset -a disablesleep 1` sets the kernel's `SleepDisabled` flag on Apple Silicon, confirmed firsthand on macOS 26.3, which keeps the Mac awake with the lid closed on battery. Verify with `pmset -g | grep SleepDisabled` (it should read `1`). Claims that it "stopped working" usually describe `caffeinate` or caffeinate-based apps, a different mechanism.
+
Why does my Mac sleep on lid close even with Amphetamine or KeepingYouAwake?
Those use macOS power assertions, which stop the idle timer but can't override the hardware lid-close trigger. KeepingYouAwake wraps `caffeinate`, which can't do lid-closed ([#66](https://github.com/newmarcel/KeepingYouAwake/issues/66)). `pmset disablesleep`, which Sleepless uses, can.
+
Is it safe? Will it overheat or drain the battery?
It is safe for light unattended work (downloads, syncs, a hotspot). Heavy sustained load with the lid fully shut reduces airflow, so use judgement. The battery floor, Low Power Mode auto-off, and the timer all stop it before it drains the Mac.
+
Does it need sudo, a kernel extension, or a daemon?
-One tightly scoped `sudo` grant (two exact `pmset` commands) so a GUI app can flip the setting without a prompt. No kernel extension, no daemon. The whole app is a single AppKit file.
+One tightly scoped `sudo` grant (two exact `pmset` commands) so a GUI app can flip the setting without a prompt. No kernel extension, no daemon, and no privileged helper.
+
How do I stop it or remove it?
Flip the switch off, or let the timer or battery floor do it, and normal sleep returns. A reboot also resets it. `./uninstall.sh` removes the app, login item, and the sudoers grant, then proves the grant is gone.
+
Why isn't it notarized?
It is a personal open-source tool with no paid Apple Developer ID, so it is ad-hoc signed. Build from source to skip Gatekeeper, or use **Open Anyway** for the prebuilt app. The notarization steps are documented in [docs/AUDIT.md](docs/AUDIT.md).
+
## Contributing
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 31e407d..3b97147 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -49,7 +49,7 @@
```sh
brew install --cask aboudjem/tap/sleepless
-/Applications/Sleepless.app/Contents/Resources/grant.sh # one-time passwordless grant
+/Applications/Sleepless\ Agents.app/Contents/Resources/grant.sh # one-time passwordless grant
```
| 其他方式 | |
diff --git a/SECURITY.md b/SECURITY.md
index 7675308..904b8d9 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -1,6 +1,6 @@
# Security Policy
-Sleepless asks for a narrow piece of root privilege, so it owes you a precise account
+Sleepless Agents asks for a narrow piece of root privilege, so it owes you a precise account
of what that privilege is and why it is safe. This document is that account. Nothing
here is hand-waved; every claim is something you can verify on your own machine.
@@ -12,9 +12,9 @@ acknowledgement within a few days. Coordinated disclosure is appreciated.
Supported version: the latest release on the `main` branch.
-## What Sleepless actually does
+## What Sleepless Agents actually does
-Sleepless keeps a Mac awake with the lid closed by toggling an undocumented but
+Sleepless Agents keeps a Mac awake with the lid closed by toggling an undocumented but
long-standing `pmset` setting:
```
@@ -29,13 +29,13 @@ sets the kernel's `SleepDisabled` flag, which you can observe yourself:
pmset -g | grep SleepDisabled # 1 = on, 0/absent = off
```
-Because it is undocumented, Apple could change or remove it in a future macOS. Sleepless
+Because it is undocumented, Apple could change or remove it in a future macOS. Sleepless Agents
reads the live value back after every toggle, so the menu-bar state always reflects
reality rather than assuming the command worked.
## The passwordless grant — exactly what it permits
-A GUI app has no terminal to type a password into, so Sleepless runs `pmset` through a
+A GUI app has no terminal to type a password into, so Sleepless Agents runs `pmset` through a
tightly scoped `/etc/sudoers.d` drop-in. The app's one-time native setup, `install.sh`,
and `grant.sh` all install the same rule (with your numeric UID substituted for `__UID__`),
owned `root:wheel`, mode `0440`:
@@ -57,12 +57,12 @@ Consequences you can rely on:
- `sudo pmset -a sleep 0`, `sudo pmset restoredefaults`, `sudo pmset -a hibernatemode 0`,
or any other argument vector **do not match** the rule and will demand a password. The
grant cannot be widened by appending flags.
-- Sleepless calls `sudo` with an **argv array**, not a shell string
+- Sleepless Agents calls `sudo` with an **argv array**, not a shell string
(`Process.arguments` in `App.swift`), so there is no `/bin/sh -c`, no command
substitution, and no word-splitting surface inside the app.
- The ongoing passwordless grant points directly at Apple's `/usr/bin/pmset`, not at a
helper script. The classic sudoers footgun is a _user-writable_ script that root executes
- on every privileged action — rewrite it, get root. Sleepless avoids that: the rule itself
+ on every privileged action — rewrite it, get root. Sleepless Agents avoids that: the rule itself
is `root:wheel 0440`, has no wildcards, and can only invoke the two `pmset` argument
vectors above.
- During the app's one-time native setup, the root-authenticated command is generated from
@@ -84,16 +84,33 @@ you can toggle `sudo pmset -a disablesleep 1/0` manually instead and skip the gr
## Reboot resets it (a safety net you can verify)
`disablesleep` is a **runtime** setting. A reboot restores normal sleep — there is no way
-for Sleepless to leave your Mac permanently unable to sleep. Verify it yourself: toggle on,
+for Sleepless Agents to leave your Mac permanently unable to sleep. Verify it yourself: toggle on,
reboot, then `pmset -g | grep SleepDisabled` should read `0`.
-Sleepless adds a second belt-and-suspenders: a **battery-floor auto-off** (default 15%)
+Sleepless Agents adds a second belt-and-suspenders: a **battery-floor auto-off** (default 15%)
that flips the flag back to `0` while the Mac is awake and discharging, so a forgotten
"on" state can't drain the battery to empty.
+## Agent and internet monitoring
+
+The agent-aware cutoff is local-only. Sleepless Agents looks for bounded, user-owned local signals:
+validated CLI tools, known app bundle IDs, process/session signals, and optional heartbeat
+hooks for tools that need a stronger signal. It does **not** scrape windows, read screen
+contents, request Screen Recording, use Accessibility APIs, or poll vendor cloud agents that
+have no local worker/session signal.
+
+The no-internet cutoff uses macOS network path status plus a small HTTPS reachability probe.
+It acts only after a grace period, and the feature is opt-in. These checks do not change the
+sudoers grant: the only privileged commands remain the two `pmset disablesleep` toggles above.
+
+Agent setup writes local diagnostics to
+`~/Library/Caches/com.aboudjem.Sleepless/setup-diagnostics.jsonl`. The JSON Lines log is local
+only, rotates at a small size, redacts your home path, and is meant for debugging hook setup
+failures.
+
## Code signing, notarization, and Gatekeeper
-Sleepless is **ad-hoc signed and not notarized** — it has no paid Apple Developer ID. The
+Sleepless Agents is **ad-hoc signed and not notarized** — it has no paid Apple Developer ID. The
trust model is _read the source, build it yourself_. (Notarization is also not a malware
guarantee: signed, notarized macOS stealers have shipped.)
@@ -104,9 +121,9 @@ guarantee: signed, notarized macOS stealers have shipped.)
Open Anyway**, then confirm. Note: macOS 15 (Sequoia) **removed** the old
right-click → Open bypass, so the System Settings path is the supported flow on macOS 15+.
-## Why Sleepless can't be on the Mac App Store
+## Why Sleepless Agents can't be on the Mac App Store
-Some people trust App Store apps more, so it is worth saying plainly: Sleepless can never
+Some people trust App Store apps more, so it is worth saying plainly: Sleepless Agents can never
ship there, and that is a property of what it does, not an oversight.
App Review **§2.4.5(v)** states apps "may not request escalation to root privileges or use
@@ -118,7 +135,7 @@ outside their container, which the `/etc/sudoers.d` drop-in does). A privileged-
workaround does not rescue it either: a helper installed from a sandboxed app must itself be
sandboxed, so it still cannot write `/etc/sudoers.d` or run arbitrary root commands.
-The practical consequence: Sleepless is **direct-download / Homebrew only**, by design. The
+The practical consequence: Sleepless Agents is **direct-download / Homebrew only**, by design. The
verification steps below, plus building from source, are how trust is established instead.
## Verifying a download
diff --git a/ShellRunner.swift b/ShellRunner.swift
new file mode 100644
index 0000000..b6843ee
--- /dev/null
+++ b/ShellRunner.swift
@@ -0,0 +1,54 @@
+import Foundation
+
+struct CommandResult {
+ let exit: Int32
+ let out: String
+ let err: String
+}
+
+enum ShellRunner {
+ static let safePath = "/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin"
+
+ @discardableResult
+ static func run(_ launchPath: String, _ args: [String], stdinNull: Bool = false, timeout: TimeInterval? = nil) -> CommandResult {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: launchPath)
+ process.arguments = args
+ var env = ProcessInfo.processInfo.environment
+ env["PATH"] = safePath
+ env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path
+ process.environment = env
+
+ let outPipe = Pipe(), errPipe = Pipe()
+ process.standardOutput = outPipe
+ process.standardError = errPipe
+ if stdinNull { process.standardInput = FileHandle.nullDevice }
+
+ do {
+ try process.run()
+ } catch {
+ NSLog("Sleepless: failed to launch %@: %@", launchPath, error.localizedDescription)
+ return CommandResult(exit: -1, out: "", err: "launch failed: \(error.localizedDescription)")
+ }
+
+ if let timeout {
+ DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + timeout) {
+ if process.isRunning { process.terminate() }
+ }
+ }
+
+ let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
+ let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+ return CommandResult(
+ exit: process.terminationStatus,
+ out: String(data: outData, encoding: .utf8) ?? "",
+ err: String(data: errData, encoding: .utf8) ?? ""
+ )
+ }
+
+ @discardableResult
+ static func capture(_ launchPath: String, _ args: [String], timeout: TimeInterval? = nil) -> String {
+ run(launchPath, args, timeout: timeout).out
+ }
+}
diff --git a/build.sh b/build.sh
index 9309af5..427ecb0 100755
--- a/build.sh
+++ b/build.sh
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
-# build.sh — compile Sleepless.app from source with the Command Line Tools only.
+# build.sh — compile Sleepless Agents.app from source with the Command Line Tools only.
#
# No Xcode project, no Package.swift: just `swiftc` + a hand-assembled .app bundle,
# ad-hoc signed. Works from any clone (no hardcoded paths or usernames).
#
# Usage:
-# ./build.sh # build into ./build/Sleepless.app
+# ./build.sh # build into ./build/Sleepless Agents.app
# ./build.sh /Applications # build straight into /Applications
# DEST=/Applications ./build.sh # same, via env
# ./build.sh --regen-icon # re-render the .icns from make-icon.swift first
@@ -15,7 +15,8 @@
set -euo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-APP_NAME="Sleepless"
+APP_NAME="Sleepless Agents"
+EXECUTABLE_NAME="Sleepless"
# macOS arm64 target. Sleepless is verified on macOS 26 (Tahoe) / Apple Silicon.
# Override with TARGET=... (e.g. CI on a runner whose SDK predates macOS 26).
TARGET="${TARGET:-arm64-apple-macos26.0}"
@@ -42,35 +43,43 @@ echo " target: $TARGET"
command -v swiftc >/dev/null || { echo "error: swiftc not found. Install the Command Line Tools: xcode-select --install" >&2; exit 1; }
# 1. Optionally regenerate the icon from the SF Symbol (needs a GUI session for AppKit).
-ICNS="$REPO/assets/$APP_NAME.icns"
+ICNS="$REPO/assets/$EXECUTABLE_NAME.icns"
if [ "$REGEN_ICON" = "1" ]; then
echo "==> Regenerating icon from make-icon.swift"
TMP_ICON="$(mktemp -d)"
swiftc -O -framework AppKit "$REPO/make-icon.swift" -o "$TMP_ICON/mkicon"
"$TMP_ICON/mkicon" "$TMP_ICON"
- iconutil -c icns "$TMP_ICON/$APP_NAME.iconset" -o "$REPO/assets/$APP_NAME.icns"
+ iconutil -c icns "$TMP_ICON/$EXECUTABLE_NAME.iconset" -o "$REPO/assets/$EXECUTABLE_NAME.icns"
rm -rf "$TMP_ICON"
fi
[ -f "$ICNS" ] || { echo "error: missing $ICNS (run ./build.sh --regen-icon)" >&2; exit 1; }
# 2. Compile the executable.
-echo "==> Compiling App.swift"
+echo "==> Compiling Swift sources"
BIN_TMP="$(mktemp -d)"
-swiftc -O -parse-as-library -target "$TARGET" -framework AppKit -framework ServiceManagement \
- "$REPO/App.swift" -o "$BIN_TMP/$APP_NAME"
+swiftc -O -parse-as-library -target "$TARGET" \
+ -framework AppKit -framework ServiceManagement -framework Network \
+ "$REPO/AppLogger.swift" \
+ "$REPO/ShellRunner.swift" \
+ "$REPO/PowerController.swift" \
+ "$REPO/AgentMonitor.swift" \
+ "$REPO/ConnectivityMonitor.swift" \
+ "$REPO/App.swift" \
+ -o "$BIN_TMP/$EXECUTABLE_NAME"
# 3. Assemble the bundle: Contents/{Info.plist, MacOS/, Resources/.icns}
echo "==> Assembling bundle"
rm -rf "$APP"
+rm -rf "$DEST/Sleepless.app"
mkdir -p "$CONTENTS/MacOS" "$CONTENTS/Resources"
cp "$REPO/Info.plist" "$CONTENTS/Info.plist"
-cp "$BIN_TMP/$APP_NAME" "$CONTENTS/MacOS/$APP_NAME"
-cp "$ICNS" "$CONTENTS/Resources/$APP_NAME.icns"
-chmod +x "$CONTENTS/MacOS/$APP_NAME"
+cp "$BIN_TMP/$EXECUTABLE_NAME" "$CONTENTS/MacOS/$EXECUTABLE_NAME"
+cp "$ICNS" "$CONTENTS/Resources/$EXECUTABLE_NAME.icns"
+chmod +x "$CONTENTS/MacOS/$EXECUTABLE_NAME"
# Ship the grant + uninstall scripts inside the bundle so Homebrew-cask users (who get
# only the .app) can run the one-time passwordless grant and a clean uninstall.
-cp "$REPO/grant.sh" "$REPO/uninstall.sh" "$CONTENTS/Resources/"
-chmod +x "$CONTENTS/Resources/grant.sh" "$CONTENTS/Resources/uninstall.sh"
+cp "$REPO/grant.sh" "$REPO/uninstall.sh" "$REPO/reset-agent-setup.sh" "$CONTENTS/Resources/"
+chmod +x "$CONTENTS/Resources/grant.sh" "$CONTENTS/Resources/uninstall.sh" "$CONTENTS/Resources/reset-agent-setup.sh"
rm -rf "$BIN_TMP"
# 4. Ad-hoc sign with hardened runtime enabled (no Apple Developer ID needed; trust
diff --git a/docs/AUDIT.md b/docs/AUDIT.md
index 38f6f57..e3b0e19 100644
--- a/docs/AUDIT.md
+++ b/docs/AUDIT.md
@@ -10,14 +10,15 @@ machine.
## Read it in about ten minutes
-The whole app is one file. To satisfy yourself it does what it claims and nothing else:
+The app is a small set of Swift files. To satisfy yourself it does what it claims and nothing else, read:
-| Read | What you are checking |
-| ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| [`App.swift`](../App.swift) | Steady-state privilege is only `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). The one-time native setup generates the sudoers drop-in from binary constants, validates it with `visudo`, and does not run bundled scripts as root. No network calls. |
-| [`sleepless.sudoers.template`](../sleepless.sudoers.template) / [`grant.sh`](../grant.sh) | Manual setup path: the passwordless grant permits exactly those two fully-specified commands for the local numeric UID (`#501`-style), has no wildcards, and installs `root:wheel 0440`. |
-| [`build.sh`](../build.sh) | `swiftc` + a hand-assembled, ad-hoc-signed bundle with hardened runtime enabled. No downloaded blobs, no install-time scripts baked into the binary. |
-| [`uninstall.sh`](../uninstall.sh) | Removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset …` prompts again. |
+- [`App.swift`](../App.swift) and [`PowerController.swift`](../PowerController.swift): steady-state privilege is only `sudo -n /usr/bin/pmset -a disablesleep 0/1` (`setDisableSleep`). The one-time native setup generates the sudoers drop-in from binary constants, validates it with `visudo`, and does not run bundled scripts as root.
+- [`AgentMonitor.swift`](../AgentMonitor.swift): agent detection is bounded and local-only: CLI validation, known app bundle IDs, user-owned processes, and optional heartbeat files. No UI scraping, Screen Recording, Accessibility, or cloud-agent polling.
+- [`AppLogger.swift`](../AppLogger.swift): setup diagnostics are written only to a small rotating JSON Lines cache under `~/Library/Caches/com.aboudjem.Sleepless/`.
+- [`ConnectivityMonitor.swift`](../ConnectivityMonitor.swift): no-internet auto-off uses macOS network path status plus a lightweight HTTPS reachability probe and does not affect the privileged grant.
+- [`sleepless.sudoers.template`](../sleepless.sudoers.template) and [`grant.sh`](../grant.sh): manual setup path: the passwordless grant permits exactly those two fully-specified commands for the local numeric UID (`#501`-style), has no wildcards, and installs `root:wheel 0440`.
+- [`build.sh`](../build.sh): `swiftc` + a hand-assembled, ad-hoc-signed bundle with hardened runtime enabled. No downloaded blobs, no install-time scripts baked into the binary.
+- [`uninstall.sh`](../uninstall.sh): removes the app, the login item, and the sudoers drop-in, then proves `sudo -n pmset ...` prompts again.
The single privileged file on your system is `/etc/sudoers.d/sleepless-disablesleep`. Read
it, and `sudo rm` it any time to revoke everything.
@@ -61,11 +62,13 @@ cd Sleepless && git checkout v
# Rebuild the executable with the release's deployment target.
swiftc -O -parse-as-library -target arm64-apple-macos13.0 \
- -framework AppKit -framework ServiceManagement App.swift -o /tmp/Sleepless-rebuilt
+ -framework AppKit -framework ServiceManagement -framework Network \
+ ShellRunner.swift PowerController.swift AgentMonitor.swift ConnectivityMonitor.swift App.swift \
+ -o /tmp/Sleepless-rebuilt
# Unzip the release and compare the Mach-O inside the bundle.
ditto -x -k Sleepless-.zip /tmp/rel
-shasum -a 256 /tmp/Sleepless-rebuilt /tmp/rel/Sleepless.app/Contents/MacOS/Sleepless
+shasum -a 256 /tmp/Sleepless-rebuilt "/tmp/rel/Sleepless Agents.app/Contents/MacOS/Sleepless"
```
Caveats, stated honestly:
@@ -98,7 +101,7 @@ Note: ad-hoc-signed, unnotarized binaries draw more _heuristic_ flags than notar
read any detection in context. A clean result is reassuring, not absolute; pair it with the
attestation above.
-**Public VirusTotal report (v1.1.0):** https://www.virustotal.com/gui/file/30a43590629b6a3cd2e1610c249c137c4b235a5f319ce8d8a9e866c1fd914cde
+**Public VirusTotal report (v1.1.0):** [VirusTotal permalink](https://www.virustotal.com/gui/file/30a43590629b6a3cd2e1610c249c137c4b235a5f319ce8d8a9e866c1fd914cde)
That is the permalink for the v1.1.0 zip (the SHA-256 matches `SHA256SUMS`). It goes live once the file is submitted to VirusTotal. Browser submission requires a one-time reCAPTCHA, so submit it from your browser (drag the zip onto virustotal.com) or with the API.
@@ -115,11 +118,11 @@ xcrun notarytool store-credentials "notarytool-password" \
# Re-sign with a Developer ID cert + hardened runtime + secure timestamp.
codesign --force --options runtime --timestamp \
- --sign "Developer ID Application: ()" Sleepless.app
+ --sign "Developer ID Application: ()" "Sleepless Agents.app"
-ditto -c -k --keepParent Sleepless.app Sleepless.zip
+ditto -c -k --keepParent "Sleepless Agents.app" Sleepless.zip
xcrun notarytool submit Sleepless.zip --keychain-profile "notarytool-password" --wait
-xcrun stapler staple Sleepless.app
+xcrun stapler staple "Sleepless Agents.app"
```
Prerequisite: [Apple Developer Program, $99/yr](https://developer.apple.com/programs/whats-included/),
diff --git a/docs/LAUNCH.md b/docs/LAUNCH.md
index a03e109..08e2b51 100644
--- a/docs/LAUNCH.md
+++ b/docs/LAUNCH.md
@@ -52,7 +52,7 @@ fresh launch window.
- **Qualifies:** you built it, anyone can build/run/inspect it (open source). Good fit.
- **Title (no caps, no superlatives, no exclamation):**
- `Show HN: Sleepless – keep a Mac awake with the lid closed`
+ `Show HN: Sleepless Agents – keep a Mac awake with the lid closed`
- **Text:**
@@ -60,7 +60,7 @@ fresh launch window.
> lid shut (overnight builds, long downloads, an agent run, sharing a hotspot from my bag)
> and kept forgetting to turn it back off, which drains the battery and traps heat.
>
- > Sleepless is a tiny AppKit menu-bar app that does exactly that one thing: flips
+ > Sleepless Agents is a tiny AppKit menu-bar app that does exactly that one thing: flips
> `disablesleep` from a native switch, on battery, with no external display, and adds a
> battery-floor auto-off (default 15%) so a forgotten "on" state can't cook the battery. A
> reboot also resets it.
@@ -86,7 +86,7 @@ fresh launch window.
- **Before posting:** read the sidebar + `reddit.com/r/macapps/wiki`, check for a required
post flair (e.g. a developer/promo flair) and any karma/age gate, and look for a pinned
promo/megathread. Be an active member first.
-- **Title:** `Sleepless – open-source menu-bar app to keep your Mac awake with the lid closed (on battery, no external display)`
+- **Title:** `Sleepless Agents – open-source menu-bar app to keep your Mac awake with the lid closed (on battery, no external display)`
- **Body:**
> Open source (MIT). A small menu-bar app that keeps a MacBook awake with the lid closed,
@@ -112,7 +112,7 @@ fresh launch window.
Sundays** ("Self-promotion Sunday"), as a **self-post** (no bare links). You must have
**≥5 unrelated posts/comments in r/apple in the past month**, and self-promo must be
**≤10%** of your activity. Abuse = instant ban. Build that history organically first.
-- **Title:** `[Self-promotion Sunday] Sleepless – keep your Mac awake with the lid closed, on battery (open source)`
+- **Title:** `[Self-promotion Sunday] Sleepless Agents – keep your Mac awake with the lid closed, on battery (open source)`
- **Body:** reuse the r/macapps body above, plus one line on the security model (passwordless
sudoers limited to two exact `pmset disablesleep` commands; reboot + battery-floor reset).
@@ -123,14 +123,14 @@ fresh launch window.
- **When:** schedule for **12:01am Pacific**; for a dev tool, a weekend (esp. Sunday) is a
known lower-competition slot. Prime ~300–400 warm people beforehand; PH amplifies momentum,
it does not create it. "Launched" is not "Featured" (PH curates the homepage).
-- **Name:** Sleepless
+- **Name:** Sleepless Agents
- **Tagline (≤60 chars):** `Keep your Mac awake with the lid closed`
- **Assets:** gallery images **1270×760**, thumbnail **240×240** (reuse the violet brand +
- coffee cup; `assets/social-preview.png` is a good base), a short demo (use `assets/demo.gif`).
+ agent-with-coffee mark; `assets/social-preview.png` is a good base), a short demo (use `assets/demo.gif`).
- **Topics:** Mac, Developer Tools, Productivity, Open Source.
- **First maker comment:**
- > Hi PH. I built Sleepless because I kept dropping to `sudo pmset disablesleep 1` to keep my
+ > Hi PH. I built Sleepless Agents because I kept dropping to `sudo pmset disablesleep 1` to keep my
> MacBook running with the lid shut (overnight builds, hotspot in my bag) and kept forgetting
> to turn it off. It is a tiny, open-source menu-bar switch that does just that, on battery,
> with no external display, plus a battery-floor auto-off so it is safe to forget. Native,
diff --git a/docs/LISTINGS.md b/docs/LISTINGS.md
index 3ed6381..87176e3 100644
--- a/docs/LISTINGS.md
+++ b/docs/LISTINGS.md
@@ -1,6 +1,6 @@
# Listings & directories
-Where Sleepless can be listed, what is automatable, and ready-to-fire drafts for the
+Where Sleepless Agents can be listed, what is automatable, and ready-to-fire drafts for the
places that need your own account. The narrative launch posts (Show HN, Reddit, Product
Hunt, AlternativeTo, MacUpdate) live in [LAUNCH.md](LAUNCH.md); this file is the directory
and awesome-list map.
@@ -35,7 +35,7 @@ category `"menubar"` (lowercase). Descriptions end with a period. One PR.
"short_description": "Keep your MacBook awake with the lid closed, on battery, with no external display, with a battery-floor auto-off.",
"categories": ["menubar"],
"repo_url": "https://github.com/Aboudjem/Sleepless",
- "title": "Sleepless",
+ "title": "Sleepless Agents",
"languages": ["swift"]
}
```
@@ -47,7 +47,7 @@ Edit `README.md` under `### System Related Tools`, alphabetical, insert just abo
Mirror into `README-zh.md` / `README-ja.md` / `README-ko.md` (maintainer prefers all locales
synced). Description ends with a period.
```markdown
-* [Sleepless](https://github.com/Aboudjem/Sleepless) - Keep your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon]
+* [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Keep your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon]
```
`[OSS Icon]` and `[Freeware Icon]` reference labels are already defined in the file.
@@ -57,7 +57,7 @@ between `ShiftIt` and `SlowQuitApps`. Title case, description ends with a period
template fully (the maintainer closes non-compliant PRs). Native AppKit passes the no-Electron
rule.
```markdown
-- [Sleepless](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon]
+- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off. [![Open-Source Software][OSS Icon]](https://github.com/Aboudjem/Sleepless) ![Freeware][Freeware Icon]
```
### 4. jaywcjlove/awesome-swift-macos-apps
@@ -66,7 +66,7 @@ auto star + last-commit badges, no text badges. This list already has near-ident
(Aquarium, StayAwake, StayUp), so **lead with the battery-floor auto-off differentiator** to
avoid a redundancy flag.
```markdown
-- [Sleepless](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off.
+- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) - Menu bar utility that keeps your MacBook awake with the lid closed on battery, with a battery-floor auto-off.
```
## C. Mac-Menubar-Megalist (issue, not PR)
@@ -74,20 +74,20 @@ avoid a redundancy flag.
Open an issue at https://github.com/SKaplanOfficial/Mac-Menubar-Megalist/issues/new. The
keep-awake cluster is `## Utilities → #### Caffeinators`. **Disambiguate from two existing
look-alikes**: "Sleepless Mac" (github.com/gsurma/sleepless_mac) and "Sleep Blocker (Sleepless
-Mode)" (App Store). No entry named exactly "Sleepless" exists yet.
+Mode)" (App Store). No entry named exactly "Sleepless Agents" exists yet.
-- **Title:** `Add app: Sleepless`
+- **Title:** `Add app: Sleepless Agents`
- **Body:**
- > **App Name:** Sleepless
+ > **App Name:** Sleepless Agents
> **URL:** https://github.com/Aboudjem/Sleepless
> **Website:** https://aboudjem.github.io/Sleepless/
> **Category:** Utilities → Caffeinators
> **License:** MIT (free, open source)
>
> Paste-ready list line:
- > `- [Sleepless](https://github.com/Aboudjem/Sleepless) by [Adam Boudjemaa](https://github.com/Aboudjem). Keeps your MacBook awake with the lid closed on battery, with no external display and a battery-floor auto-off. Free, open source.`
+ > `- [Sleepless Agents](https://github.com/Aboudjem/Sleepless) by [Adam Boudjemaa](https://github.com/Aboudjem). Keeps your MacBook awake with the lid closed on battery, with no external display and a battery-floor auto-off. Free, open source.`
>
- > Note: this is "Sleepless" by Aboudjem, distinct from the existing "Sleepless Mac"
+ > Note: this is "Sleepless Agents" by Aboudjem, distinct from the existing "Sleepless Mac"
> (gsurma) and "Sleep Blocker (Sleepless Mode)" entries already in the Caffeinators list.
## D. Human-account drafts (fire these yourself; do not automate)
@@ -95,7 +95,7 @@ Mode)" (App Store). No entry named exactly "Sleepless" exists yet.
### MacMenuBar.com
Submit at https://macmenubar.com/submit-your-menu-bar-app/ (menu-bar-only directory; check it
is not already listed first).
-> **Name:** Sleepless
+> **Name:** Sleepless Agents
> **Category:** Utilities / Menu Bar
> **Description:** Open-source macOS menu-bar app that keeps your MacBook awake with the lid
> closed, on battery, with no external display, using `pmset disablesleep`. Adds an auto-off
@@ -112,7 +112,7 @@ https://mac.softpedia.com/ (editor-reviewed). The "100% Clean" badge is a nice R
> Category: Utilities. Description as above. Note macOS 26 / Apple Silicon requirement.
### opensourcealternative.to
-https://www.opensourcealternative.to/submit (OSS-only; Sleepless qualifies).
+https://www.opensourcealternative.to/submit (OSS-only; Sleepless Agents qualifies).
> Position as an open-source alternative to Amphetamine and KeepingYouAwake. Tags: macOS,
> menu-bar, keep-awake, productivity, open-source.
@@ -122,9 +122,9 @@ spending time.
### r/SideProject
Self-post; promotion is welcomed here. Lead with the demo GIF.
-> **Title:** Sleepless – a tiny open-source Mac menu-bar app that keeps your MacBook awake with the lid closed (on battery, no external display)
+> **Title:** Sleepless Agents – a tiny open-source Mac menu-bar app that keeps your MacBook awake with the lid closed (on battery, no external display)
> **Body:** I kept typing `sudo pmset -a disablesleep 1` to keep my MacBook running with the
-> lid shut for overnight jobs, then forgetting to turn it back off. Sleepless is that command
+> lid shut for overnight jobs, then forgetting to turn it back off. Sleepless Agents is that command
> as a one-click menu-bar switch, with an auto-off timer and a battery-floor cutoff so it can't
> drain the battery. Native AppKit, no daemon, no kext, MIT. Build from source or `brew install
> --cask aboudjem/tap/sleepless`. Feedback welcome. https://github.com/Aboudjem/Sleepless
@@ -132,11 +132,11 @@ Self-post; promotion is welcomed here. Lead with the demo GIF.
### r/swift
Frame as an engineering write-up (90/10 etiquette: mostly substance, light promo). Read the
sidebar first.
-> **Title:** How I keep a MacBook awake lid-closed from a single-file AppKit menu-bar app (`pmset disablesleep` + a scoped sudoers grant)
+> **Title:** How I keep a MacBook awake lid-closed from a tiny AppKit menu-bar app (`pmset disablesleep` + a scoped sudoers grant)
> **Body:** A short write-up of the mechanism (`pmset disablesleep` sets the kernel
> `SleepDisabled` flag, unlike `caffeinate` which can't override lid-close), how a GUI app runs
> it passwordless via a tightly scoped `/etc/sudoers.d` rule (two exact commands, no wildcards),
-> and the Swift 6 single-file `@main` setup. Source: https://github.com/Aboudjem/Sleepless
+> and the small native Swift/AppKit setup. Source: https://github.com/Aboudjem/Sleepless
### MacRumors macOS Apps forum
Evergreen thread, ranks in Google. Post in the macOS Apps forum with the demo + a short,
@@ -153,7 +153,7 @@ Pitch the lid-closed-on-battery angle + the security model, with the demo GIF.
## E. Blocked / cannot submit
- **Homebrew core cask**: self-submission needs 90 forks / 90 watchers / 225★ (or 75★ if an
- unaffiliated person submits). Sleepless is far under, so stay on `aboudjem/tap`. Revisit after
+ unaffiliated person submits). Sleepless Agents is far under, so stay on `aboudjem/tap`. Revisit after
225★. https://docs.brew.sh/Acceptable-Casks
- **Changelog Nightly**: algorithmic from GitHub Archive; can't submit, only earned via a
star-velocity spike. Coordinate with a launch.
diff --git a/docs/adr/0001-local-agent-detection.md b/docs/adr/0001-local-agent-detection.md
index 090aa63..945d3f2 100644
--- a/docs/adr/0001-local-agent-detection.md
+++ b/docs/adr/0001-local-agent-detection.md
@@ -1,3 +1,3 @@
# Local Agent Detection
-Sleepless monitors only locally observable coding-agent work: CLI processes, local workers, session signals, or app-wide hook/heartbeat integrations. It deliberately avoids UI scraping, broad filesystem searches, Screen Recording, and cloud-only vendor API monitoring because agent auto-off is a power-control safety feature; guessing from private UI or remote state would be brittle, privacy-sensitive, and easy to misrepresent to users. Tools participate only when Sleepless has a bounded, reliable detector for them, and app-wide integrations are required only when default local detection is not reliable enough.
+Sleepless Agents monitors only locally observable coding-agent work: CLI processes, local workers, session signals, or app-wide hook/heartbeat integrations. It deliberately avoids UI scraping, broad filesystem searches, Screen Recording, and cloud-only vendor API monitoring because agent auto-off is a power-control safety feature; guessing from private UI or remote state would be brittle, privacy-sensitive, and easy to misrepresent to users. Tools participate only when Sleepless Agents has a bounded, reliable detector for them, and app-wide integrations are required only when default local detection is not reliable enough.
diff --git a/docs/index.html b/docs/index.html
index 35f7aff..b77a010 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -1,35 +1,41 @@
+
-
-
-Sleepless: keep your Mac awake with the lid closed
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
Keep your Mac awake. Lid closed.
-
Sleepless keeps your MacBook awake with the lid closed, on battery, with no external display. One native menu-bar switch, with an auto-off timer and a battery-floor cutoff so you never drain it flat.
macOS 26 · Apple Silicon · MIT · no Dock icon, no daemon, no kext
-
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
Trust it, then forget it
+
+
+
+
+
+
+
Keep your Mac awake. Lid closed.
+
Sleepless Agents keeps your MacBook awake with the lid closed, on battery, with no external
+ display.
+ One native menu-bar switch, with an auto-off timer and a battery-floor cutoff so you never drain it flat.
Most keep-awake apps ride on macOS power assertions. Assertions stop the idle timer, but they can't override the hardware lid-close trigger, so a closed lid still sleeps the Mac. Sleepless flips the one setting that can.
-
-
01
Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close trigger.
-
02
So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.
-
03
pmset disablesleep overrides lid-close sleep. Sleepless flips exactly that, reads it back, and adds safety nets.
-
-
“I will probably never support this option because caffeinate doesn't support this. KYA uses caffeinate under the hood.”KeepingYouAwake maintainer, issue #66
-
-
-
-
-
-
Small on purpose
-
Everything it does, nothing it doesn't
-
-
-
-
One switch
Click the coffee cup, flip the toggle. Empty cup is off, full cup is awake, full cup with a dot is awake on battery. The menu-bar glyph never lies about the real state.
-
-
-
-
Auto-off timer
Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.
-
-
-
-
Battery-floor cutoff
Pick a floor from 5 to 50% (default 15%). On battery, it turns itself off before you run flat.
+
+
+
+
+
The one that actually does it
+
Why other keep-awake apps can't do this
+
Most keep-awake apps ride on macOS power assertions. Assertions stop the idle timer, but they
+ can't override the hardware lid-close trigger, so a closed lid still sleeps the Mac. Sleepless flips the one
+ setting that can.
+
+
01
+
Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close
+ trigger.
+
+
02
+
So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.
+
+
03
+
pmset disablesleep overrides lid-close sleep. Sleepless flips exactly that, reads it back, and
+ adds safety nets.
+
-
-
-
Low Power Mode aware
On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.
+
“I will probably never support this option because caffeinate doesn't
+ support this. KYA uses caffeinate under the hood.”KeepingYouAwake maintainer, issue
+ #66
+
+
+
+
+
+
Small on purpose
+
Everything it does, nothing it doesn't
+
+
+
+
One switch
+
Click the menu-bar agent, flip the toggle. Outline is off, filled is awake, filled with a dot is awake on
+ battery. The menu-bar glyph never lies about the real state.
+
+
+
+
Auto-off timer
+
Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.
+
+
+
+
Battery-floor cutoff
+
Pick a floor from 5 to 50% (default 15%). On battery, it turns itself off before you run flat.
+
+
+
+
Low Power Mode aware
+
On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.
+
+
+
+
No display, no dongle
+
Just the lid closed, on battery. No external monitor, no dummy HDMI plug.
+
+
+
+
Tiny and native
+
Small AppKit codebase with SF Symbols. No Dock icon, no background daemon, no kext, no UI scraping.
+
-
-
-
No display, no dongle
Just the lid closed, on battery. No external monitor, no dummy HDMI plug.
Sleepless toggles pmset disablesleep, which flips the kernel's
+ SleepDisabled flag, then reverts it at your battery floor, in Low Power Mode, or when the timer
+ ends. A reboot also resets it. Because a GUI app can't type a password, the installer adds a tightly scoped
+ sudoers rule that permits exactly two commands and nothing else.
+
+
+
/etc/sudoers.d/sleepless
+
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
-
-
-
Tiny and native
One AppKit file with SF Symbols. No Dock icon, no background daemon, no kext, no dependencies.
+
+
+
+
Can't be widened
+
sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.
+
+
+
+
No privileged helper to hijack
+
The keep-awake toggle calls Apple's /usr/bin/pmset directly with an argv array. No daemon and no privileged
+ helper script.
+
+
+
+
Always reversible
+
A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves
+ it.
+
+
+
+
Verifiable downloads
+
Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check.
+
Menu-bar glyph: empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live).
-
-
-
-
-
-
How it works
-
One setting, flipped safely
-
Sleepless toggles pmset disablesleep, which flips the kernel's SleepDisabled flag, then reverts it at your battery floor, in Low Power Mode, or when the timer ends. A reboot also resets it. Because a GUI app can't type a password, the installer adds a tightly scoped sudoers rule that permits exactly two commands and nothing else.
-
-
/etc/sudoers.d/sleepless
-
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
-
-
-
Can't be widened
sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.
-
No helper to hijack
It calls Apple's /usr/bin/pmset directly with an argv array. No daemon, no shell, no helper script.
-
Always reversible
A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves it.
-
Verifiable downloads
Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check.
+
+
+
+
+
As of 2026-06
+
Sleepless vs the alternatives
+
+
+
+
+
+
Sleepless Agents
+
Amphetamine
+
KeepingYouAwake
+
caffeinate
+
+
+
+
+
Awake, lid closed, no monitor
+
Yes ¹
+
Flaky ²
+
No ³
+
No
+
+
+
On battery
+
Yes
+
Yes
+
Lid open
+
Partial ⁴
+
+
+
Auto-off timer
+
Yes
+
Yes
+
Yes
+
No
+
+
+
Auto-off on low battery
+
Yes
+
Yes
+
Yes
+
No
+
+
+
Open source
+
MIT
+
App Store
+
MIT
+
Apple
+
+
+
Cost
+
Free
+
Free
+
Free
+
Free
+
+
+
+
+
¹ Sleepless uses pmset disablesleep, the mechanism built for lid-close, and reads the
+ flag back so the UI reflects reality; behavior on any keep-awake tool is hardware and macOS-version dependent. ²
+ Amphetamine documents closed-display mode but is widely reported to fail on Apple Silicon when the power source
+ changes (Amphetamine-Enhancer #28); the app itself is closed source. ³ KeepingYouAwake cannot keep the lid
+ closed by design, since it wraps caffeinate (issue #66). ⁴ caffeinate -i runs on
+ battery; -s is AC-only.
+
+ How do I keep my MacBook awake with the lid closed without a monitor?
+
Install Sleepless, click the agent glyph in the menu bar, flip the switch on, and close the lid (the laptop
+ screen, also called the flap). It keeps the Mac awake on battery with no external display, using
+ pmset disablesleep. No dummy HDMI plug or clamshell adapter is needed.
+
+
+
+ Why does my MacBook sleep when I close the lid even with Amphetamine or KeepingYouAwake?
+
Those tools are built on macOS power assertions, which stop the idle timer but cannot override the hardware
+ lid-close trigger. KeepingYouAwake wraps caffeinate, which its maintainer confirms cannot do
+ lid-close. pmset disablesleep, which Sleepless uses, is a lower-level setting that can.
+
+
+ Does pmset disablesleep still work on Apple Silicon (M1/M2/M3)?
+
Yes. pmset -a disablesleep 1 sets the kernel's SleepDisabled flag on Apple Silicon,
+ confirmed firsthand on macOS 26.3, which keeps a MacBook awake with the lid closed on battery. Apple does not
+ officially document the setting, so verify it with pmset -g | grep SleepDisabled. Most claims
+ that it stopped working describe caffeinate, a different mechanism.
+
+
+ Is it safe to run a MacBook with the lid closed? Will it overheat or drain the battery?
+
It is safe for light, unattended work like downloads, syncs, or a hotspot. Heavy sustained load with the lid
+ fully closed reduces airflow, so use judgement there. Sleepless turns itself off at the floor you set and in
+ Low Power Mode, and the auto-off timer caps how long it stays on.
+
+
+ Does Sleepless require sudo, a kernel extension, or a background daemon?
+
It needs one tightly scoped sudo grant (two exact pmset commands, nothing else) so
+ a GUI app can flip the setting without a password prompt. There is no kernel extension and no background
+ daemon, and no privileged helper.
+
+
+ Can I run AI agents or long jobs overnight with the lid closed?
+
Yes. Switch Sleepless on, set a battery floor, close the lid, and an agent run, build, render, or training
+ job keeps going. Plug in for an all-nighter, or stay on battery with a floor and timer so it stops itself
+ before the battery runs low.
+
-
¹ Sleepless uses pmset disablesleep, the mechanism built for lid-close, and reads the flag back so the UI reflects reality; behavior on any keep-awake tool is hardware and macOS-version dependent. ² Amphetamine documents closed-display mode but is widely reported to fail on Apple Silicon when the power source changes (Amphetamine-Enhancer #28); the app itself is closed source. ³ KeepingYouAwake cannot keep the lid closed by design, since it wraps caffeinate (issue #66). ⁴ caffeinate -i runs on battery; -s is AC-only.
-
-
-
-
-
-
Questions
-
FAQ
- How do I keep my MacBook awake with the lid closed without a monitor?
Install Sleepless, click the coffee cup in the menu bar, flip the switch on, and close the lid (the laptop screen, also called the flap). It keeps the Mac awake on battery with no external display, using pmset disablesleep. No dummy HDMI plug or clamshell adapter is needed.
- Why does my MacBook sleep when I close the lid even with Amphetamine or KeepingYouAwake?
Those tools are built on macOS power assertions, which stop the idle timer but cannot override the hardware lid-close trigger. KeepingYouAwake wraps caffeinate, which its maintainer confirms cannot do lid-close. pmset disablesleep, which Sleepless uses, is a lower-level setting that can.
- Does pmset disablesleep still work on Apple Silicon (M1/M2/M3)?
Yes. pmset -a disablesleep 1 sets the kernel's SleepDisabled flag on Apple Silicon, confirmed firsthand on macOS 26.3, which keeps a MacBook awake with the lid closed on battery. Apple does not officially document the setting, so verify it with pmset -g | grep SleepDisabled. Most claims that it stopped working describe caffeinate, a different mechanism.
- Is it safe to run a MacBook with the lid closed? Will it overheat or drain the battery?
It is safe for light, unattended work like downloads, syncs, or a hotspot. Heavy sustained load with the lid fully closed reduces airflow, so use judgement there. Sleepless turns itself off at the floor you set and in Low Power Mode, and the auto-off timer caps how long it stays on.
- Does Sleepless require sudo, a kernel extension, or a background daemon?
It needs one tightly scoped sudo grant (two exact pmset commands, nothing else) so a GUI app can flip the setting without a password prompt. There is no kernel extension and no background daemon. The whole app is a single AppKit file.
- Can I run AI agents or long jobs overnight with the lid closed?
Yes. Switch Sleepless on, set a battery floor, close the lid, and an agent run, build, render, or training job keeps going. Plug in for an all-nighter, or stay on battery with a floor and timer so it stops itself before the battery runs low.
-
-
-
-
-
-
-
-
Keep it awake in two minutes
-
Install with Homebrew, or build it from source and read every line first.