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 4c4d254..efb1a94 100644
--- a/App.swift
+++ b/App.swift
@@ -11,83 +11,140 @@
// 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, a Low-Power-Mode auto-off switch, 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 a tiny robot whose eyes read at a glance: asleep (gently closed
+// eyes) means the Mac sleeps normally, awake (open eyes) means it is being kept awake,
+// and an awake robot 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.
// 2. Launch at login (SMAppService.mainApp) — OFF by default. The app always
// launches reading the TRUE system state, so a login launch can never
// 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.
+// 3. Low-Power-Mode auto-off (user toggle, ON by default) — on battery, if Low Power
+// Mode is on, Sleepless turns itself off. Evaluated on the same tick as the battery
+// floor; a deliberate turn-on overrides it for the session (the hard floor never).
+// 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
+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 lpmAutoOffKey = "lowPowerAutoOffEnabled"
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 robot glyph (hand-drawn MONOCHROME template — state by EXPRESSION)
// 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.
+// bars and inverts on highlight. We draw a BOLD, FILLED robot silhouette (a solid head that fills
+// the bar height, with negative-space eyes + smile + little side ears) so it stays clear and
+// legible at menu-bar size — thin outlines read as faint and fuzzy. The FACE communicates state,
+// matching the app icon's robot identity:
+// OFF (sleeps normally) = robot asleep — gently closed eyes
+// ON (kept awake, on power) = robot awake — open round eyes
+// ARMED (kept awake, on battery) = awake robot + a small dot (auto-off safety net live)
+// Drawn from vectors (not SF Symbols, which have no robot glyph) so it re-renders crisply at the
+// menu bar's backing scale.
enum SleepGlyph {
case off
case on
case armed
}
-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")?
- .withSymbolConfiguration(cfg)
- ?? NSImage(systemSymbolName: "cup.and.saucer.fill", accessibilityDescription: "Sleepless")
- ?? NSImage()
-
- guard glyph == .armed else {
- base.isTemplate = true
- return base
- }
- // ARMED: full steaming cup + a small filled dot top-right (the "auto-off safety net is live"
- // mark). Drawn in template black so it tints + inverts with the menu bar exactly like the cup.
- let size = base.size
- guard size.width > 0, size.height > 0 else { base.isTemplate = true; return base }
- let composed = NSImage(size: size)
- composed.lockFocus()
- base.draw(in: NSRect(origin: .zero, size: size))
- let d = max(size.height * 0.26, 4)
- let dot = NSBezierPath(ovalIn: NSRect(x: size.width - d, y: size.height - d, width: d, height: d))
- NSColor.black.setFill()
- dot.fill()
- composed.unlockFocus()
- composed.isTemplate = true
- return composed
+private func makeRobotGlyph(_ glyph: SleepGlyph) -> NSImage {
+ let asleep = (glyph == .off)
+ let showDot = (glyph == .armed)
+ // A bold, filled robot face that nearly fills the canvas height so it reads clearly in the
+ // bar. State reads from the EYES — open round dots when awake, gently closed slits when asleep.
+ let W: CGFloat = 19
+ let H: CGFloat = 17
+
+ // A thin downward-curving crescent (used as a negative-space hole for closed eyes / smile).
+ func crescent(_ cx: CGFloat, _ cy: CGFloat, halfW: CGFloat, thick: CGFloat) -> CGPath {
+ let p = CGMutablePath()
+ let l = CGPoint(x: cx - halfW, y: cy)
+ let r = CGPoint(x: cx + halfW, y: cy)
+ p.move(to: l)
+ p.addQuadCurve(to: r, control: CGPoint(x: cx, y: cy - thick)) // bottom edge (lower)
+ p.addQuadCurve(to: l, control: CGPoint(x: cx, y: cy)) // top edge (back)
+ p.closeSubpath()
+ return p
+ }
+
+ let img = NSImage(size: NSSize(width: W, height: H), flipped: false) { _ in
+ guard let cg = NSGraphicsContext.current?.cgContext else { return true }
+ cg.setFillColor(NSColor.black.cgColor)
+
+ // Big, central head (a soft, helmet-like rounded square) that fills the bar height.
+ let headW: CGFloat = 13.0, headH: CGFloat = 13.0
+ let head = CGRect(x: (W - headW) / 2, y: (H - headH) / 2 - 0.2, width: headW, height: headH)
+ let corner = headW * 0.32
+ let midX = head.midX
+
+ // Ears: little rounded nubs on each side at head mid-height (no antennae). Filled so
+ // they merge into the silhouette.
+ let earW: CGFloat = 2.0, earH: CGFloat = 5.2
+ func earRect(_ sign: CGFloat) -> CGRect {
+ let ex = sign < 0 ? head.minX - earW * 0.55 : head.maxX - earW * 0.45
+ return CGRect(x: ex, y: head.midY - earH / 2, width: earW, height: earH)
+ }
+ for sign in [-1.0, 1.0] as [CGFloat] {
+ cg.addPath(CGPath(roundedRect: earRect(sign), cornerWidth: earW / 2, cornerHeight: earW / 2, transform: nil))
+ }
+ cg.fillPath()
+
+ // Head silhouette with negative-space eyes + smile punched out via even-odd fill.
+ let p = CGMutablePath()
+ p.addPath(CGPath(roundedRect: head, cornerWidth: corner, cornerHeight: corner, transform: nil))
+ let eyeDX: CGFloat = 2.9
+ let eyeY = head.midY + 1.0
+ if asleep {
+ for s in [-eyeDX, eyeDX] { p.addPath(crescent(midX + s, eyeY + 0.3, halfW: 1.9, thick: 1.1)) }
+ } else {
+ let r: CGFloat = 1.85
+ for s in [-eyeDX, eyeDX] {
+ p.addEllipse(in: CGRect(x: midX + s - r, y: eyeY - r, width: r * 2, height: r * 2))
+ }
+ }
+ p.addPath(crescent(midX, head.midY - 2.6, halfW: 2.7, thick: 1.05)) // smile
+ cg.addPath(p)
+ cg.fillPath(using: .evenOdd)
+
+ // ARMED: a small filled dot at the top-right corner (auto-off safety net live).
+ if showDot {
+ let d: CGFloat = 3.0
+ cg.fillEllipse(in: CGRect(x: W - d - 0.2, y: H - d - 0.2, width: d, height: d))
+ }
+ return true
+ }
+ img.isTemplate = true
+ return img
}
// Flipped container so popover content lays out top-down with simple frames.
@@ -139,27 +196,47 @@ 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)
- private let offGlyph = makeCupGlyph(.off)
- private let armedGlyph = makeCupGlyph(.armed)
+ private let onGlyph = makeRobotGlyph(.on)
+ private let offGlyph = makeRobotGlyph(.off)
+ private let armedGlyph = makeRobotGlyph(.armed)
// Popover UI
private let popover = NSPopover()
private var toggleSwitch: NSSwitch!
private var mainCard: CardView! // group-1 card; gets the brand-violet wash when awake
- private var headerMark: NSImageView! // header coffee mark; tints violet when awake
+ private var headerMark: NSImageView! // header robot mark; tints violet when awake
private var captionLabel: NSTextField!
private var floorValueLabel: NSTextField!
private var floorSlider: NSSlider!
private var autoOffControl: NSSegmentedControl!
private var countdownLabel: NSTextField!
+ private var internetSwitch: NSSwitch!
+ private var lowPowerSwitch: NSSwitch!
+ 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 lowPowerAutoOffEnabled = true // ON by default: matches the long-standing safety behavior
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
@@ -167,12 +244,16 @@ 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 = 644
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)
+ // Unset => true, so existing installs keep the Low Power Mode safety net they already had.
+ lowPowerAutoOffEnabled = (UserDefaults.standard.object(forKey: lpmAutoOffKey) as? Bool) ?? true
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.image = offGlyph
@@ -204,15 +285,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
root.blendingMode = .behindWindow
root.state = .followsWindowActiveState
- // Header: small coffee mark + "Sleepless" (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
- mark.image = headerCup
+ // Header: small robot mark + app name. The mark tints to the brand violet while
+ // the Mac is kept awake (an awake robot face — matching the app icon identity).
+ let mark = NSImageView(frame: NSRect(x: pad, y: 13, width: 19, height: 18))
+ let headerRobot = makeRobotGlyph(.on); headerRobot.isTemplate = true
+ mark.image = headerRobot
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)
@@ -228,7 +309,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let swH = swProto.height > 0 ? swProto.height : 21
// GROUP 1 — main switch + state caption
- let g1y: CGFloat = 46, g1h: CGFloat = 84
+ let g1y: CGFloat = 46, g1h: CGFloat = 78
let g1 = makeCard(NSRect(x: pad, y: g1y, width: contentW, height: g1h))
mainCard = g1
let rowLabel = makeLabel("Keep awake with lid closed", font: .systemFont(ofSize: 13), color: .labelColor)
@@ -240,7 +321,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
toggleSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH)
g1.addSubview(toggleSwitch)
captionLabel = makeLabel("", font: .systemFont(ofSize: 12), color: .secondaryLabelColor)
- captionLabel.frame = NSRect(x: ci, y: ci + 30, width: cw, height: 32)
+ captionLabel.frame = NSRect(x: ci, y: ci + 28, width: cw, height: 30)
captionLabel.usesSingleLineMode = false
captionLabel.lineBreakMode = .byWordWrapping
captionLabel.maximumNumberOfLines = 2
@@ -248,10 +329,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 = 58
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,
@@ -262,56 +343,117 @@ 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 + 26, 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 = 140
let g3 = makeCard(NSRect(x: pad, y: g3y, width: contentW, height: g3h))
+ let agentsLabel = makeLabel("Auto-off when agents are idle", 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 = 46
+ 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)
+
+ // GROUP 4b — Low Power Mode auto-off (surfaces the battery-side safety net as a control,
+ // grouped next to the battery floor since both protect a discharging battery)
+ let glpy = g4y + g4h + 10, glph: CGFloat = 46
+ let glp = makeCard(NSRect(x: pad, y: glpy, width: contentW, height: glph))
+ let lpmLabel = makeLabel("Auto-off in Low Power Mode", font: .systemFont(ofSize: 13), color: .labelColor)
+ lpmLabel.frame = NSRect(x: ci, y: ci, width: cw - swW - 8, height: 22)
+ glp.addSubview(lpmLabel)
+ lowPowerSwitch = NSSwitch()
+ lowPowerSwitch.target = self
+ lowPowerSwitch.action = #selector(lowPowerAutoOffToggled(_:))
+ lowPowerSwitch.state = lowPowerAutoOffEnabled ? .on : .off
+ lowPowerSwitch.frame = NSRect(x: contentW - ci - swW, y: ci + (22 - swH) / 2, width: swW, height: swH)
+ glp.addSubview(lowPowerSwitch)
+
+ // GROUP 5 — battery-floor (label + value + slider + min/max hints)
+ let g5y = glpy + glph + 10, g5h: CGFloat = 80
+ 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 + 22, 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 + 44, 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 + 44, 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()
@@ -329,19 +471,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
return t
}
- // MARK: - Click the menu-bar cup to open/close the popover
+ // MARK: - Click the menu-bar robot to open/close the popover
@objc private func statusClicked() {
if popover.isShown { closePopover() } else { openPopover() }
}
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
@@ -352,6 +496,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 }
}
@@ -388,44 +533,72 @@ 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 {
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)
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
}
- // 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
+ 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 robot
+ // expression) 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.
private func pulseStatusItem() {
guard let b = statusItem.button else { return }
@@ -438,7 +611,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) {
@@ -478,7 +659,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() {
@@ -498,6 +679,151 @@ 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 lowPowerAutoOffToggled(_ sender: NSSwitch) {
+ lowPowerAutoOffEnabled = sender.state == .on
+ UserDefaults.standard.set(lowPowerAutoOffEnabled, forKey: lpmAutoOffKey)
+ // Re-evaluate now so enabling it while already in Low Power Mode can take effect this
+ // tick, and the caption's cutoff list updates immediately. A deliberate turn-on this
+ // session (userForcedOn) still wins; the hard battery floor still always wins.
+ refresh()
+ }
+
+ @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
+ }
+
// MARK: - Launch at login (Feature 2) — OFF by default; never re-enables sleep prevention
@objc private func loginToggled(_ sender: NSSwitch) {
do {
@@ -523,7 +849,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
isOn = on
if !on { cancelKeepAwakeTimer() } // going OFF clears any countdown/timer
// ARMED = kept awake while actively discharging on battery, so the
- // auto-off safety net is live. Distinct menu-bar glyph (cup + dot).
+ // auto-off safety net is live. Distinct menu-bar glyph (awake robot + dot).
var armed = false
if on {
let (onBattery, discharging, _) = batteryStatus()
@@ -531,30 +857,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
if let button = statusItem.button {
let newImage = on ? (armed ? armedGlyph : onGlyph) : offGlyph
- if button.image !== newImage { // state (cup shape) changed -> swap + pulse
+ if button.image !== newImage { // state (robot expression) changed -> swap + pulse
button.image = newImage
pulseStatusItem()
}
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). " + (lowPowerAutoOffEnabled
+ ? "Auto-off at \(batteryFloorPercent)% or in Low Power Mode."
+ : "Auto-off at \(batteryFloorPercent)%.")
+ : "\(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 {
+ // Each cutoff is a self-contained phrase so the sentence reads naturally no matter
+ // which optional safety nets are on (the hard battery floor is always present).
+ var cutoffs = ["below \(batteryFloorPercent)% battery"]
+ if lowPowerAutoOffEnabled { cutoffs.append("in Low Power Mode") }
+ if internetAutoOffEnabled { cutoffs.append("with no internet") }
+ if agentAutoOffEnabled { cutoffs.append("with no agents running") }
+ captionLabel?.stringValue = "Stays awake with the lid closed. Turns off " + cutoffs.joined(separator: ", ") + "."
+ } else {
+ captionLabel?.stringValue = "Sleeps normally when you close the lid."
+ }
}
@objc private func floorSliderChanged(_ sender: NSSlider) {
@@ -566,125 +904,100 @@ 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 (user-controllable; ON by default), UNLESS the user
+ // deliberately chose to keep awake this session. Toggle off => LPM is ignored entirely.
+ if lowPowerAutoOffEnabled && 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
}
- // 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 healthy = lastAgentSnapshots.filter { $0.status != .setupNeeded }
+ guard !healthy.isEmpty else { noAgentsSince = nil; return }
+ if healthy.contains(where: { $0.status == .active }) {
+ noAgentsSince = nil
+ return
+ }
+ 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 \"\(message)\" with title \"Sleepless\" sound name \"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])
}
- // 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) ?? ""
+ 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)\""
}
@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..36db283 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,39 @@ 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.
+- Low Power Mode auto-off is now an explicit switch in the popover (ON by default). The
+ battery-side safety net that was already running silently is now visible and can be
+ turned off if you want to stay awake on battery even in Low Power Mode. The hard battery
+ floor and a deliberate same-session turn-on still behave as before.
+- Local agent-detection documentation and an ADR that rules out UI scraping, Screen
+ Recording, broad filesystem searches, and cloud-only monitoring.
+
+### Changed
+
+- Reworded the "keep awake" caption so each auto-off cutoff reads as a complete phrase
+ ("Turns off below 15% battery, in Low Power Mode, ...") instead of the previous
+ "Turns off at 15% battery, Low Power Mode" fragment.
+- Split the native app into focused Swift files for power control, command execution,
+ agent monitoring, and connectivity monitoring.
+- Redesigned the app identity around a friendly AI/chatbot robot on a purple plate (a
+ white "helmet" head with a lavender visor, big eyes, side ears, and straight, vertical
+ knob-tipped antennae). The menu-bar mark stays a monochrome, template-safe robot — now a
+ bold, filled face with little ears (no antennae) that fills the bar and reads clearly —
+ whose state reads from the eyes alone: gently closed when off (the Mac sleeps normally),
+ open when keeping the Mac awake, and open with a small dot when awake on battery (the
+ auto-off safety net is live).
+
## [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 +51,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 +63,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 +71,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 +79,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 +87,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 +97,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 +108,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 +119,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 +145,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 +160,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 +170,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 +179,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
new file mode 100644
index 0000000..394d2c7
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,22 @@
+# Context
+
+## Glossary
+
+- **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 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 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 robot logo**: The AI/chatbot robot brand mark (white "helmet" head with a lavender visor, big eyes, straight vertical knob-tipped antennae, and side ears, on a purple plate) used for app and marketing identity. The menu bar uses a simplified monochrome template glyph (a bold, filled robot face with little ears, no antennae) derived from the same idea.
+- **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 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 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 @@
+
pmset disablesleep still work on Apple Silicon (M1/M2/M3)?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.
- - - - -
+
+
+
+ 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.
+ + + +It holds one scoped sudo grant and runs your Mac unattended, so every release is built in the open and signed so you can prove what you ran.
gh attestation verify.SHA256SUMS. No Apple account needed to check.pmset flag.
+ shasum -a 256 -c SHA256SUMS
+ It holds one scoped sudo grant and runs your Mac unattended, so every release
+ is built in the open and signed so you can prove what you ran.
gh attestation verify.SHA256SUMS. No Apple account needed to
+ check.shasum -a 256 -c SHA256SUMS gh attestation verify Sleepless-1.2.0.zip -R Aboudjem/Sleepless+
The one that actually does it
-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.
-Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close trigger.
So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.
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-caffeinatedoesn't support this. KYA usescaffeinateunder the hood.”KeepingYouAwake maintainer, issue #66
Small on purpose
-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.
-Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.
-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
+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.
+Power assertions (Caffeine, Theine, caffeinate) stop the idle timer, but not the lid-close
+ trigger.
So a closed lid still sleeps the Mac, even with Amphetamine or KeepingYouAwake.
+pmset disablesleep overrides lid-close sleep. Sleepless flips exactly that, reads it back, and
+ adds safety nets.
On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.
+“I will probably never support this option because+caffeinatedoesn't + support this. KYA usescaffeinateunder the hood.”KeepingYouAwake maintainer, issue + #66
Small on purpose
+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.
+Keep awake for 1 or 2 hours with a live countdown, then it turns itself off. In memory only.
+Pick a floor from 5 to 50% (default 15%). On battery, it turns itself off before you run flat.
+On battery, if Low Power Mode is on, Sleepless steps aside and lets the Mac sleep.
+Just the lid closed, on battery. No external monitor, no dummy HDMI plug.
+Small AppKit codebase with SF Symbols. No Dock icon, no background daemon, no kext, no UI scraping.
+Just the lid closed, on battery. No external monitor, no dummy HDMI plug.
+Menu-bar glyph: outline agent = off · filled agent = awake · filled agent + dot = + awake on + battery (auto-off live).
+How it works
+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.
+
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1
One AppKit file with SF Symbols. No Dock icon, no background daemon, no kext, no dependencies.
+sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.
+The keep-awake toggle calls Apple's /usr/bin/pmset directly with an argv array. No daemon and no privileged + helper script.
+A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves + it.
+Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check. +
+Full threat model and verification: SECURITY.md and AUDIT.md.
Menu-bar glyph: empty cup = off · full cup = awake · full cup + dot = awake on battery (auto-off live).
-How it works
-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.
<you> ALL=(root) NOPASSWD: /usr/bin/pmset -a disablesleep 0, /usr/bin/pmset -a disablesleep 1-
sudoers matches arguments literally with no wildcards, so any other command re-prompts for a password.
It calls Apple's /usr/bin/pmset directly with an argv array. No daemon, no shell, no helper script.
A reboot resets the flag, the floor and timer turn it off, and uninstall.sh removes the grant and proves it.
Releases ship SHA-256 sums and a Sigstore build-provenance attestation. No Apple account needed to check.
As of 2026-06
+| + | 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.
Full threat model and verification: SECURITY.md and AUDIT.md.
-As of 2026-06
-| 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 |
Questions
+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.
+
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.
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.
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.
+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.
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
-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.
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.
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.
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.
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.
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.
Install with Homebrew, or build it from source and read every line first.
-Install with Homebrew, or build it from source and read every line first.
+