Skip to content
Conduit

skill

agent-caffeine

Keep your Mac awake while AI coding agents work, also with the lid closed. Free and open source.

mertbuilds/agent-caffeinecommunitylistedunreviewed

Set up

Skills are installed by placing their folder where your agent loads skills.

The download comes from github.com/mertbuilds/agent-caffeine itself, not from our server, so what you get is exactly what its author published.

Install instructions come from the README

Skills do not use an MCP config. Follow the steps in the README below.

README

synced Oct 22, 2026

Agent Caffeine

Agent Caffeine is a macOS menu bar app that keeps your Mac awake while AI coding agents work, and lets it sleep when they are done. Start a long task in Claude Code, Codex, Cursor or another agent, close the lid, and come back to finished work.

It is free and open source. There is no license key, no account and no price. If it helps you, you can support the project with an amount you choose.

This repository holds the app and its website, caffeinagent.com. The website is in site/.

Install

  • Download the disk image from caffeinagent.com, open it and drag Agent Caffeine to Applications.
  • Or with Homebrew: brew install --cask mertbuilds/tap/agent-caffeine

It needs macOS 13 or later, on Apple silicon or Intel. The app updates itself from caffeinagent.com.

What it does

  • It finds the agents that run on your Mac: Claude Code, Codex, Cursor, opencode, Gemini CLI, Grok Build, Hermes Agent, OpenClaw and about 40 more.
  • It keeps the Mac awake while an agent works, for a short time after it finishes, and for a while when it waits for you. Then the Mac sleeps as usual.
  • With its helper set up, the Mac also stays awake with the lid closed on battery.
  • It lets the Mac sleep when the battery is low and when the Mac gets too hot. With the lid closed on battery it stops staying awake for agents after 3 hours awake or a loss of 30 points of charge.
  • A script or an agent can ask for the Mac to stay awake with the command line tool agent-caffeine, or through its MCP server.
  • "Wake for jobs" (off by default) wakes the Mac for a scheduled job and keeps it awake while the job runs.
  • "Join your iPhone hotspot" (beta, off by default) joins the hotspot of your iPhone when the Mac loses its Wi-Fi while an agent works.

Limits

  • The state of an agent is exact only where the agent says it: Claude Code, Codex, and the agents with hooks (see Hooks). For the others Agent Caffeine reads signs of work, such as a running command or processor use. It can be wrong both ways: the Mac can sleep under an agent that works quietly, and stay awake for one that only looks busy. Settings has a rule per agent for that.
  • Some agents of the catalog were found from their documents and not yet measured in a real turn. docs/research/ says what was measured for each.
  • A closed lid on battery keeps the Mac awake only with the helper, which runs as root and needs your approval once in System Settings > General > Login Items. It is offered only when the app is in the Applications folder.
  • A Mac that stays awake in a bag gets warm and uses its battery. The battery limit (20% by default) and the heat stop are there for that, and they end a hold whatever asked for it. The closed lid limit is there for agents: it lets go of every agent session, and not of what you asked for from the command line or of the mode "Always". Work that needs the Mac awake stops then.
  • The display may still sleep. Agent Caffeine keeps the Mac awake, not the screen.
  • Under a closed lid macOS turns the built-in display off. Agent Caffeine asks for display sleep only when the display is still on 15 seconds later, and when a call to the display hangs, it stops changing the display until it starts again.
  • "Wake for jobs" sets no wake time on battery unless you allow it, and cannot see a Mac that is unplugged while it sleeps.
  • "Join your iPhone hotspot" finds the hotspot only while the phone shows it. An iPhone stops showing it about 90 seconds after the last device left. See its section.
  • There is no warranty. See the license.

What it does, in detail

  • Finds the agents that run on the Mac from the process table and a built-in catalog (Claude Code, Codex, opencode, Antigravity and about 45 more). Settings has one rule per agent (automatic, always working, ignore) and a list for process names of tools that are not in the catalog.

  • Reads what each agent says about itself where it can: Claude Code's session state files and Codex's session logs give working, waiting for you, or idle. Other agents are judged by signs of work: a running command, a background task, a power assertion of their own, writes to their session files and, as weak evidence, processor use.

  • Takes the state from an agent's hooks when you switch them on (see Hooks below). A fresh hook state comes before everything else.

  • Three modes, picked in the menu or in Settings:

    • Always: keeps the Mac awake until you switch it off, agents or not.
    • While an agent is open: keeps the Mac awake while any agent is detected, working or idle.
    • While an agent is working (default): keeps the Mac awake while an agent works, for a short time after it finishes (2 minutes by default), and for a while when it waits for you (5 minutes by default).
  • While keeping the Mac awake it holds PreventUserIdleSystemSleep and PreventSystemSleep assertions, and releases them as soon as the mode no longer calls for them. The assertions carry a 2 minute timeout that every decision renews, so a hung app cannot keep the Mac awake.

  • After macOS says the Mac goes to sleep, Agent Caffeine starts no hold and takes no sleep flag until the Mac woke in full (macOS says so, or the displays come on) and a sample from after that wake was taken. A sleeping Mac wakes by itself with the display off many times, and what the app knew before the sleep (a pause that ended in the night, a state that still says "working") starts nothing there. A hold that runs is not broken by the word of sleep alone: it ends when a hole between two samples shows that the Mac did sleep. The rule is Caffeinagent/Detection/WakeGate.swift.

  • Lets the Mac sleep when the battery is low (20% by default) or the Mac gets too hot, and says so in a notification. The battery floor stops every hold on battery, with the lid open or closed (Caffeinagent/Power/SafetyMonitor.swift). It also stops a hold on a charger that is too weak to hold the level (a phone charger, a power bank): macOS then still reports wall power, so the floor counts when the level fell by 2 points or more while on power.

  • "Sleep when the battery is low" (Settings > Battery and lid, on by default) switches the battery floor off, after one question ("Let the battery run empty?"). With it off, Agent Caffeine keeps the Mac awake until the battery is empty: the Mac then turns off at once, and work that is not saved can be lost. A notification says so once at 10% and once at 5% while Agent Caffeine keeps the Mac awake on battery. The closed lid limit is for agents alone, so with the floor off nothing but heat ends a hold from the command line or the mode "Always" before its own end: they can empty the battery with the lid closed, and Settings says so. The stop for heat has no switch.

  • Closed lid limit: a last stop for agents, so a session that hangs does not empty the battery in a bag. It asks no rule of the detection. While the lid is closed and the Mac runs on battery, Agent Caffeine lets go of every agent session, whatever it says or does, after it kept the Mac awake for 3 hours, or when the battery lost 30 points, whichever comes first. What you or a program asked for holds on past the limit: a hold from the command line or through MCP, a job hold of "Wake for jobs", "stay reachable" and the mode "Always". Their net is the battery floor, the heat stop and their own time caps, and the limit cancels no wake alarm. The mode "Always" has no time cap. The 3 hours and the 30 points count whoever keeps the Mac awake: the battery does not care who asked. The 3 hours are time kept awake, not time with the lid closed: a Mac that is kept awake for 20 minutes of each hour reaches them after 9 hours. One closed-lid period begins at the first hold with the lid closed on battery and ends only when the lid opens or power comes: a hold that pauses and starts again under the closed lid is the same period, and time asleep does not count. The agents stay let go until the lid opens or power is connected, then the next period starts fresh. When the limit ends the last hold, a notification says why ("Agent Caffeine let your Mac sleep because it kept it awake for 3 hours with the lid closed on battery"), with the agent that still worked, and so does the line in the menu after the sleep. While another hold keeps the Mac awake no such notification comes: the menu names that hold, and a line below says that agents do not keep the Mac awake. When that hold ends, the Mac was kept awake for longer than the limit, so the notification and the line after the sleep name the limit and not the time ("Agent Caffeine let your Mac sleep. Agents stopped keeping it awake earlier, when the closed lid limit of 3 hours was reached"). Settings > Battery and lid > Closed lid on battery has the choices: 1, 2, 3, 6 or 12 hours or "No limit", and 15%, 30%, 50% or "No limit". Heat, the battery floor, the switch and a pause come first. It does not apply on power or with the lid open. The rule is Caffeinagent/Power/ClosedLidLimit.swift.

  • Offline rule: most agents cannot work without the internet. When the Mac has had no way to the network for 10 minutes without a break, agent sessions stop counting as a reason to keep it awake, in every power and lid state. What you or a program asked for holds on: a hold from the command line or through MCP, a job hold of "Wake for jobs", "stay reachable" and the mode "Always". An agent whose child command uses the processor (a build, a test) holds on too, because that work needs no network. An agent that uses a model on this Mac (Ollama, LM Studio) can work offline, and the rule lets go of it all the same: the model server is not a child command of the agent. If your agents use a model on this Mac, choose "Never" in Settings > General. Offline means that macOS reports no usable way to the network (NWPathMonitor): the loopback and the tunnel of a VPN alone do not count as a way. Agent Caffeine sends no request to find this out, so a Wi-Fi that is connected with no internet behind it is not seen and counts as online. The 10 minutes are awake time: a sleep counts for nothing and the count starts fresh after it, so a Mac that is opened in a place with no network gives its agents 10 minutes. When the way comes back, the agents count again at once. When the rule ends the last hold, a notification says why ("Agent Caffeine let your Mac sleep because it was offline for 10 minutes. Claude Code was still working"), and so does the line in the menu after the sleep. Settings > General has the choices: 5, 10 or 30 minutes, or "Never". The rule is Caffeinagent/Detection/OfflineRule.swift.

  • Battery line: while the Mac runs on battery and Agent Caffeine could keep it awake, the menu shows one small line below the headline: a battery mark filled to the charge, the charge, and what follows from it ("58% · sleeps below 20%"). The mark's level and the number are amber within 10 points of the floor, and the mark and the number are red with "starts again at 25%" while the floor stopped Agent Caffeine; the menu then has no second line that says the same. With the battery floor off the line says "no battery limit", and it is amber at 10% and below and red at 5% and below. It is hidden on wall power, on a Mac without a battery, and while Agent Caffeine is off or paused.

  • Durations read as a person says them: "22 minutes", "1 hour 22 minutes" up to 10 hours, then "13 hours", then "3 days" (Caffeinagent/Detection/HumaneDuration.swift).

  • Surfaces state in the menu bar with a small version of the robot of the app icon, in the same pose as the robot in the menu: a solid head with smiling eyes when keeping the Mac awake, round eyes while an agent is waiting for you, a grey outline with closed eyes when the Mac can sleep or Agent Caffeine is paused or off, and red crosses for eyes after a safety stop. The menu names what keeps the Mac awake and lists each agent with its state and the reason.

  • "Show in menu bar" (Settings > General, on by default) hides the icon. The app keeps your Mac awake the same way without it. To open Settings again, open Agent Caffeine from Applications or Spotlight. While the icon is hidden, a notification says when the helper was turned off or cannot be reached.

  • Pause: "Pause for 30 minutes", "Pause for 1 hour" or "Pause until tomorrow" (06:00, or 8 hours when the morning is less than 4 hours away), from the timer button next to the switch. While paused Agent Caffeine holds nothing and clears the lid closed setting, as if it were off, and starts again by itself. The end is a time on the clock ("Paused until 15:30") and is saved, so it lasts over a restart of the app, and the time the Mac sleeps counts: a Mac that slept past the end wakes with the pause over. Switching Agent Caffeine off ends a pause, and "Resume" ends it at once. The rule is Caffeinagent/Detection/Pause.swift.

  • Stay reachable: "Stay reachable for 1 hour", "for 2 hours" or "for 4 hours", from the same timer button. It is the mirror of a pause: until the time on the clock ("Reachable until 18:30") the Mac stays awake also when no agent works, and with the lid closed on battery when the helper is set up, so a message from your phone (Claude Code Remote Control, Codex remote) reaches the agent in seconds. An agent that works keeps its own line in the menu. It costs about 1% of battery per hour on the Mac it was measured on (docs/research/07-display-and-remote-wake.md), and the menu says so on battery. "Stop" ends it, a pause ends it, switching Agent Caffeine off ends it, and a safety stop ends it for good: it does not start again when the power returns. With the battery floor off only heat is a safety stop, so the battery does not end it. Starting it ends a pause. The end is saved like the end of a pause. The rule is Caffeinagent/Detection/Reachable.swift.

  • Agent notifications (Settings > Notifications), each with its own switch: "An agent waits for you" (on by default) and "An agent finishes" (off by default), both with the system sound. A session finished when its turn ends or its process is gone, also when a command it started runs on, and not when it works again within 2 seconds. It waits when it asks a question or needs a permission and still does so after 3 seconds. Each change gives one notification with an identifier of its own, and the older one about the same session leaves Notification Center. "Only when its app is not in front" (on by default) skips the notification while you use the app the agent runs in. Three or more agents that change within 2 seconds give one notification. Nothing is told for what was already there at launch, or while Agent Caffeine is off or paused. A click on a notification about one agent brings the app that agent runs in to the front, looked up at the click. A click on one about several agents does what macOS does by itself. macOS asks for permission the first time one is due or when you switch one on. Settings > Notifications lists the last 5 notifications and what became of them, and every decision is in the log with the pid, the state and the time, and no name: log show --last 1h --info --predicate 'subsystem == "com.caffeinagent.app" AND category == "Notices"'. caffeinagent --lab-notices <recording> replays a lab recording and lists what would be told. The notifications about a safety stop, and the ones at 10% and at 5% with the battery floor off, have no switch. The name of a session can come from what you typed: it is shown in the notification on your Mac and is never logged or recorded. The rules are Caffeinagent/Detection/AgentNotices.swift.

  • "Open at login" (Settings > General) registers the app with SMAppService.mainApp, only from /Applications. The last page of the setup window asks with a checked box, and nothing is registered when the setup is skipped. The state mapping is Caffeinagent/LoginItem.swift.

  • A stop has its own headline, "Agent Caffeine has stopped", with the reason below it (the battery, heat), so a row that says "Working" below it is no contradiction. The closed lid limit and the offline rule are no stop: other holds go on. With only agents there the headline is "Your Mac can sleep" with the reason below it, and the row of an agent says "Working, but your Mac is offline" or "Working, but the closed lid limit is reached". With another hold there the headline names that hold, and one line below it says that agents do not keep the Mac awake.

  • After a sleep under an agent the menu says why, once, until it is dismissed or the Mac sleeps again: "Your Mac slept at 02:14: the battery was at 20%". The app takes what it knows when macOS says the Mac goes to sleep, with the lid read at that moment, and names only reasons that are certain: a stop, the closed lid limit, the offline rule, a pause, a wait that ran out, and a lid closed on battery without the helper. A Mac that the user put to sleep by hand gets no line. The rule is Caffeinagent/Detection/SleepReason.swift.

  • A click on an agent in the menu brings the app it runs in to the front and closes the menu: the agent's own app, or the terminal or editor its command line runs in. That is the app, not the window or the tab. An app whose windows are all in the Dock shows one again, as after a click on its icon in the Dock. A line of several sessions opens its group, and each session below it goes to its own app. An agent with no app to go to takes no click: over SSH, under screen or zellij, or under a tmux or herdr with no client, or with clients in more than one app. The rules are Caffeinagent/Detection/AgentHost.swift.

  • A right click on an agent in the menu goes to its app ("Go to Cursor"), sets its rule ("This agent is working", "Ignore this agent") or opens its settings.

  • Keyboard shortcuts that work in every app (Settings > Shortcuts): "Turn on or off", which is the switch and ends a pause, and "Pause for 1 hour". The defaults are ⌃⌥⌘C and ⌃⌥⌘P; you can record others, clear them, or restore the default. They are registered with Carbon's RegisterEventHotKey, which needs no Accessibility permission. A shortcut needs Command, Control or Option, and one that macOS uses is refused (Caffeinagent/Shortcut/).

  • On power, stays awake while a tool's phone access is on, also when no agent works (see Phone access below).

The detection code is in Caffeinagent/Detection/, and the research behind it in docs/research/. caffeinagent --lab-record <label> records what the engine sees without starting the app (see Caffeinagent/LabCommand.swift). The app binary starts the app only without arguments: the lab commands, --display-probe and --smoke-test (see Before a release) are the only arguments it takes, and it refuses any other.

Hooks

Some agents can run a command when their state changes. With hooks on, an agent tells Agent Caffeine when it works, waits for you or is done, so nothing has to be inferred. Hooks are off until you switch them on, per agent, in Settings > Agents > Better detection or in the setup window.

  • Supported: Claude Code and Codex (adds "waiting for your permission"), Droid, opencode and Pi (adds working, waiting and done). Pi has no permission step of its own, so it waits only when one of its extensions asks you something. Caffeinagent/Detection/Hooks/HookCatalog.swift holds the list, what each entry was confirmed by, and the agents that are left out and why.
  • Switching hooks on adds a few lines to the agent's own config file (~/.claude/settings.json, ~/.codex/hooks.json, ~/.factory/settings.json), or writes one file of its own for opencode (~/.config/opencode/plugins/agent-caffeine.js) and for Pi (~/.pi/agent/extensions/agent-caffeine.js). The rest of the file stays as it is, a backup is kept next to it, and switching hooks off removes exactly those lines again.
  • The lines run CaffeinagentHook, a small command inside the app in /Applications, never the app itself. It reads the event, writes one small state file per agent process to ~/Library/Application Support/com.caffeinagent.app/hooks/, prints nothing and always exits 0. It keeps the state and the name of the event, and nothing you or the agent typed. The app does not have to run, and the lines do nothing when the app is gone.
  • Hooks need the app in /Applications, because the lines point there. They hold the path with the name the bundle has on disk: Agent Caffeine.app for an install of 1.0.2 or later, agent caffeine.app for one that was updated from 1.0.0 or 1.0.1. Lines with the other name of the same bundle count as current and are not rewritten. On a volume that tells names apart by case, lines with a name that is not on disk read as from another version, and "Update" rewrites them. The plugin files of opencode and Pi are rewritten at launch, only while nobody edited them.

Command line and MCP

A script, a long job or an agent that Agent Caffeine does not know can ask for the Mac to stay awake, and let it go again. The tool is agent-caffeine, inside the app at Contents/MacOS/agent-caffeine. Settings > Agents > Agent skill has "Set up for agents" (see below), and for a setup by hand it shows the path of the tool, the command that links it into ~/.local/bin, and the entry for an agent's MCP servers.

  • agent-caffeine hold --for 30m [--reason "text"] keeps the Mac awake for a time: 90s, 30m, 2h. One hour with no --for, 12 hours at most.
  • agent-caffeine hold [--reason "text"] -- <command> [args...] runs the command, keeps the Mac awake while it runs, and ends with the exit code of the command. A signal sent to the tool is passed on to the command. The hold ends after 24 hours at the latest, and the command runs on.
  • agent-caffeine release [<id> | --all] ends a hold. agent-caffeine status [--json] lists the holds and says whether the Mac can sleep.
  • agent-caffeine skill prints the skill of the app (skills/agent-caffeine/SKILL.md of this repo), so any agent can read it. The tool installs nothing.
  • agent-caffeine mcp is an MCP server on stdin and stdout with three tools: hold_awake (minutes from 1 to 720, reason), release (id, or the holds of the session) and status. A hold ends when its session ends: when the agent closes the server's input or its output, or ends the tool with a signal, the holds that session made are released.
  • A hold is one small file in ~/Library/Application Support/com.caffeinagent.app/holds/, which the app reads with every sample and watches. No daemon and no socket. A timed hold is dead at its time. A command hold is dead when the tool that holds it is gone, checked by pid and start time, so a pid that is used again keeps nothing awake. A dead hold is removed. While the tool is stopped (kill -STOP, Ctrl-Z) its hold keeps nothing awake, and it counts again when the tool is continued.
  • A hold is a reason to stay awake like an agent at work, and goes through the same decision. The battery limit, heat, a pause and the switch still let the Mac sleep. In the menu each hold has a line with "Stop".
  • hold --for is refused while the app does not run. hold -- <command> runs the command all the same and says so on stderr.
  • AGENT_CAFFEINE_HOME names another folder in place of the app's own. It is for tests and development.

Set up for agents

An agent uses the tool when a skill tells it to. "Set up for agents" at the top of Settings > Agents, and in the setup window, does that with one click:

  • It writes the skill as <skills folder>/agent-caffeine/SKILL.md for each agent whose folder is on the Mac: Claude Code (~/.claude/skills), Codex and Pi (~/.agents/skills), opencode, Cursor, Gemini CLI, Droid, Grok Build, Amp, goose and OpenClaw. An agent that reads a folder the skill is already in gets no second copy, so with Claude Code and Codex on the Mac the skill is in two folders and every agent of the list reads one of them. Caffeinagent/Detection/Skills/SkillCatalog.swift holds the folders, what each was confirmed by and the agents that are left out, and docs/research/13-agent-skills.md the research.
  • It links the tool as ~/.local/bin/agent-caffeine, from the copy in /Applications only: a copy that runs from another folder writes the skill and says to move the app there. A link to a copy of the app that is gone reads as not linked, and "Link" makes it again. When ~/.local/bin does not seem to be on the PATH of your login shell, Settings shows the line to add. The app tells that from the startup files of the shell, /etc/paths and /etc/paths.d, which it only reads: it runs no shell. The skill also names the full path of the tool, so it works without the link.
  • It writes its own folder agent-caffeine and that one link, and nothing else. A folder, a file or a link of that name that Agent Caffeine did not make is left as it is, and Settings says so. That holds for a link to something that is gone too: only a link into a copy of the app that is gone is made again. A skills folder that is a link to a place outside the agent's folder is not written to. The file it writes has the line <!-- agent-caffeine-skill v=1 --> after the frontmatter, which is how the app knows its own.
  • Settings then says so in one line. "Details" lists each agent with its state, and is open when something waits for you, with "Install" next to an agent that came to the Mac later and "Update" next to a skill of an older version. At launch the app replaces a skill of its own from an older version, only while nobody edited it, and points the link at the copy in /Applications when the app made that link or the copy it points at is gone. A link you made by hand to another copy stays. It installs nothing new by itself.
  • "Remove" takes out the files, the link and the folders the app made, each folder only when it is empty. A skill you edited stays, and Settings names it. What it made is recorded in ~/Library/Application Support/com.caffeinagent.app/skill-installs/record.json.
  • A change of the text of the skill needs a new SkillCatalog.version and its digest in CaffeinagentTests/SkillTextTests.swift.

Setup window

The first launch opens a setup window once. It names the agents it found, offers the hooks of the agents that have them, offers "Set up for agents" when an agent that reads skills is on the Mac, and offers the lid closed helper on a Mac with a battery. Every step can be skipped, and the app works the same while the window is open. Everything in it is also in Settings.

Phone access

Claude Code Remote Control lets you work with a session on this Mac from a phone or the web, and that only works while the Mac is awake. With "Stay reachable while plugged in" (Settings > General, on by default), the Mac stays awake on power while Remote Control is on, also when no agent works. The menu then says "Reachable while plugged in". On battery this does not hold the Mac awake: "Stay reachable" in the menu is the timed version that you start yourself and that also works on battery.

Remote Control counts as on for a claude remote-control server, for a session started with --remote-control or --rc, and for every interactive session when remoteControlAtStartup is true in ~/.claude/settings.json. A session that turns it on later with /remote-control is not seen. No other tool has a detector yet (Caffeinagent/Detection/RemoteAccessDetector.swift).

Lid closed on battery

kIOPMAssertionTypePreventSystemSleep keeps the Mac awake with the lid closed only on AC power. On battery, macOS sleeps on lid close regardless, and an assertion alone cannot override that. Only the system-wide SleepDisabled setting does, and it needs root.

The app sets it through a small root helper (CaffeinagentHelper/), registered with SMAppService from Settings > Battery and lid and approved once in System Settings > General > Login Items. The helper is offered only when the app runs from /Applications. It takes two booleans and one wake time over XPC from the signed app and nothing else: no text, no path, no command. The two booleans set and clear the flag. The wake time is for one wake alarm, which "Wake for jobs" asks for (see below): the helper schedules one wake event under its own name (com.caffeinagent.app.wake in pmset -g sched), 20 seconds to 8 days ahead, and 0 takes it away. It never makes a power-on event, never touches an event of macOS or of another app, takes at most 30 new times per hour, and takes its alarm away when the app did not ask for it again in 10 minutes of awake time, when launchd stops it and at its own start. The rules are CaffeinagentHelper/WakeAlarmCore.swift. The app sets the flag together with the power assertions, also on AC, so unplugging with the lid closed does not race it, and sends a heartbeat. The helper clears the flag when the heartbeats stop, at its own start, and when launchd stops it, and never clears a flag it did not set. After an update of the app the helper that still runs is the old binary: the app asks it to leave, and launchd starts the new one on the next call with no change of the registration. A helper that does not know that call is removed and registered again. SleepDisabled also blocks the kernel's low-battery and thermal sleep, which is why the app watches both itself.

The display under a closed lid

"Turn off the display" (Settings > Battery and lid, on by default) keeps the built-in display dark while Agent Caffeine keeps the Mac awake with the lid closed. The rules are Caffeinagent/Power/DisplayRules.swift:

  • No external display: macOS turns the built-in display off by itself about 5 seconds after the lid closes, also while the Mac stays awake. Agent Caffeine asks for display sleep (IORequestIdle on IODisplayWrangler, the request pmset displaysleepnow makes, and that command when the request is refused) only as a late fallback: when the built-in display still reads on 15 seconds after the lid closed and 15 seconds after the displays last changed, at most once per 5 seconds and twice per closed lid. When sleep does not hold, the brightness of the built-in display also goes to zero. Keys or a pointer that move under the closed lid mean someone uses the Mac from afar: for a minute after them the display is only dimmed, never put to sleep.
  • With an external display: display sleep would take the display you work on, so only the brightness of the built-in display goes to zero, 2 seconds after the lid closed or once the displays settled after it, not while macOS changes the displays for the closed lid. A built-in display that macOS took off the list of displays is not touched.
  • The brightness is set through the private DisplayServices framework, and on an Intel Mac also through IOKit's IODisplaySetFloatParameter, whichever works first. Your value is saved before the display goes dark and put back when the lid opens, when the setting goes off, when a stop ends the hold, when macOS says the Mac goes to sleep, when Agent Caffeine quits, and at the next launch after a crash. Putting it back never makes the display darker than it is. While the lid is closed it is put back only to a built-in display that is on, and when the lid opens it waits until macOS has the display on again.
  • Every brightness call and every request for display sleep runs on a queue of its own, one after the other. A call that takes longer than 200 ms turns the display feature off until Agent Caffeine starts again: it makes no more display calls of its own. When the lid opens, when the Mac goes to sleep or when Agent Caffeine quits, one write of the saved brightness is queued behind the call that hung: it runs once that call returns, and the app does not wait for it. If it never runs, the saved brightness comes back at the next start. The decisions, every write and every failure are in the log (log show --predicate 'subsystem == "com.caffeinagent.app" && category == "Display"'), which is kept on disk. A read that worked is at debug level, which is not kept.

"Sound when the lid closes" (Settings > Battery and lid, on by default) plays a short system sound once when the lid closes while Agent Caffeine keeps the Mac awake: on wall power the hold is enough, on battery the helper must have confirmed the sleep flag, for which the sound waits up to 1.5 seconds. No sound means the Mac goes to sleep. The rules are Caffeinagent/Power/LidChime.swift.

The lid and the list of displays are watched (kIOPMMessageClamshellStateChange of the power root domain and the display reconfiguration callback). The rules run at once when the lid switch changes, not at the next sample of the engine. A change of the displays is not acted on in its callback: from its first word no brightness and no sleep call is made, and the rules run once, 2 seconds after macOS said the last display is done.

"Dim as the lid closes" (under it, on by default, shown only on a Mac with a lid angle sensor) makes the brightness follow the lid angle. The rules are Caffeinagent/Power/LidDimming.swift:

  • At 45 degrees and above the brightness is yours and is not touched. From 45 degrees down to 3 degrees it is your brightness times a smoothstep curve of the angle (half at 24 degrees). At 3 degrees and below, or when the lid switch says closed, it is zero. Opening is the same curve the other way: the brightness rises at the first reading above 3 degrees.
  • When the lid switch says closed, the fade is over: the sensor is closed, and nothing is read or written while the lid stays shut. When the lid opens after that, no fade comes up while macOS turns the display on: your brightness comes back in one step once the switch says open and the built-in display is on.
  • The sensor gives whole degrees, and one degree is up to 3.6% of the brightness. So the curve is fed from an angle without steps (LidAngleEstimator.swift): it walks to each new value of the sensor at the speed the lid was measured to have, never passes it, and leaves out one degree of noise. The brightness is written up to 30 times a second, in steps of 0.1% or more.
  • Your brightness is read when the lid comes below 45 degrees, and the fade stays on that value: what auto-brightness does under a lid that covers the light sensor is not taken for yours. A brightness key that you press while the lid rests between 20 and 45 degrees gives the new value (BrightnessKeyDetector.swift).
  • A lid that rests between 20 and 45 degrees for 1.5 seconds is in use: your brightness comes back, and the fade starts again when the lid moves down.
  • It works while Agent Caffeine keeps the Mac awake. When it does not, the Mac sleeps at lid close and macOS turns the display off. A fade that started is finished the same way when the hold ends in the middle.
  • The sensor is a HID device of the Mac (vendor 0x05AC, product 0x8104, usage page 0x0020, usage 0x008A) that needs no permission. It is asked every 0.5 seconds while Agent Caffeine keeps the Mac awake, 30 times a second while the lid moves below 70 degrees, and not at all otherwise or while the lid is shut. The brightness is written in the same step that reads the angle. When the sensor gives no answer for 0.5 seconds, your brightness comes back.
  • With the setting off, or on a Mac without the sensor, macOS turns the display off when the lid closes, and the rules above make sure it stays dark.

caffeinagent --display-probe <seconds> prints what the lid, the lid angle sensor, each display, the brightness readers and the keyboard backlight say: once per second, and 20 times a second while the lid is below 70 degrees. The lidEstimate column is the angle without the steps of the sensor, and wouldSet is the brightness the fade would set. It only reads, and starts no app.

To see what a fade did, switch the trace on: defaults write com.caffeinagent.app Display.debugTrace -bool YES. The running app reads the switch at the start of each fade, so it needs no restart. Each close and each open of the lid then leaves display-trace-<time>.tsv in ~/Library/Application Support/com.caffeinagent.app/, written while the fade runs, at least once per second, so a hard restart keeps it: one line per step with the seconds since the start, the angle of the sensor, the angle without steps, the brightness the fade wants, the brightness it set, the brightness read back after it, and a 1 in fight when that is more than 1% from what the fade set last (macOS set its own). A file has 2000 lines at most and the newest 5 files are kept. It holds angles and brightness only. defaults delete com.caffeinagent.app Display.debugTrace switches it off. The probe writes the same file for its dry run, with no value in written.

Builds before the helper used a sudoers entry for pmset -b disablesleep. The app clears a flag such a build left on at first launch, and Settings offers to remove the entry.

Wake for jobs

"Wake for jobs" (Settings > Battery and lid, off by default) wakes the Mac one minute before a scheduled job, keeps it awake while the job runs, for 30 minutes at most, and then lets it sleep by its own timers. It needs the helper, and it works only in the copy in the Applications folder.

  • What it finds: launchd agents in ~/Library/LaunchAgents with StartCalendarInterval, the lines of your crontab (crontab -l), and the cron jobs of Hermes Agent (~/.hermes/cron/jobs.json). Nothing is read while the switch is off. Each job has a switch of its own, off at first: Agent Caffeine adopts nothing by itself, and it edits no crontab, no plist and no file of an agent. A job that runs at more than 48 times of a day is listed and cannot be switched on, also when it runs on some days only. The same goes for a crontab line that cannot be told apart from another one (the same times and the same command name), or whose command has no plain name: its switch could move to another line when the crontab changes. When a job that was on loses its switch this way, the history and a line in Settings say so.
  • What it keeps of a job: its times and a short name (the launchd label, the name of the command without its folder and its arguments, "Hermes job" with the id of a Hermes job). No command line, no argument, no environment and no prompt is kept, shown or logged. The name of a Hermes job is not read either: Hermes Agent makes it from the start of the prompt when you give none.
  • One alarm at a time, for the nearest job that is on. A Mac that is awake gets no new alarm for a job in the next 30 minutes; the plan is made again when macOS says the Mac goes to sleep. An alarm that is set stays until its job ran. No alarm is set more than 8 days ahead: the helper refuses it, and Settings says that the next run to wake for is that far away. That can be the run after the next one: a monthly job that runs in 20 minutes on an awake Mac gets no alarm for that run, and its run of next month is too far. The app asks the helper for the alarm again every 3 minutes, and the helper takes away an alarm that nobody asked for in 10 minutes of awake time. It also takes the alarm away 30 seconds of awake time after the last connection of the app ended (the app died, or quit with no cancel), unless the app connects again in that time, as after an update. The alarm is taken away when the feature or Agent Caffeine is switched off, during a pause, in a safety stop, when the app quits and before the helper is removed. At most 12 alarms in 24 hours.
  • The hold: at the time of the alarm a job hold opens, and it goes through the same decision as an agent at work, so the battery limit, heat, a pause and the switch still let the Mac sleep. It ends when the job was seen to run and is gone (a launchd job by launchctl list <label>, a crontab job by a process below cron with the name of the command), 4 minutes after the time of a job that was never seen, or 30 minutes after the alarm. The hold of a Hermes job is a bridge of 5 minutes: from the moment the agent works, the detection holds the Mac. With the lid closed that does not happen in this version: the alarm brings no full wake, so Agent Caffeine starts no hold for an agent, and a Hermes job has the 5 minutes of the bridge and no more. On battery no hold opens and none goes on while no alarm would be set there, so a Mac that was unplugged while it slept is not kept awake by an alarm that was set on power. In the menu the hold has a line with "Stop", and a set alarm has one quiet line ("Wakes at 09:14 for backup").
  • Limits: "Only on power" is on by default, so on battery no alarm is set and no job keeps the Mac awake. An alarm that was set on power is taken away when Agent Caffeine sees the battery; it cannot see a Mac that is unplugged while it sleeps, so that alarm can still wake the Mac once, and the Mac sleeps again. With it off, an alarm is set on battery only while the battery limit is on and the charge is more than 5 points above it. With the lid closed on battery this is not tested yet: the wake may last a few seconds only, and a Mac can wake in a bag. After the job Agent Caffeine sends no sleep request: with the lid open the Mac sleeps when its own sleep timer says so. launchd runs a missed calendar job at the next wake anyway, so there the wake buys punctuality; cron skips a job that falls into a sleep.
  • "Show history" lists the last 50 events with their time: alarm set, refused or taken away, the wake, the job seen and ended, and how a hold ended.
  • The rules are in Caffeinagent/Detection/Jobs/: JobPlanner.swift (which alarm), JobWake.swift (the loop), JobHold.swift (the hold) and JobRunProbe.swift (how a job is found among the processes). The alarm window of the wake gate is in Caffeinagent/Detection/WakeGate.swift.

iPhone hotspot (Beta)

"Join your iPhone hotspot" (Settings > General, off by default) is for the moment you leave a cafe with the lid closed: when the Mac loses its Wi-Fi while an agent works, it joins the hotspot of your iPhone, so the agent stays online. It is a beta: read the limits before you rely on it.

  • Setup: switch it on, type the name of the hotspot (the name of your iPhone) and its password, and click "Save". The name is kept in the settings of the app and the password in your login Keychain. Switching it off forgets both. If the Keychain does not remove the password, Settings says so and About keeps "Forget", so you can try again. A straight apostrophe and the curly one of iOS ("Mert’s iPhone") count as the same name.
  • Location Services: from macOS 14 on, macOS shows the names of Wi-Fi networks only to an app you allowed in Location Services, so "Save" asks for it. Agent Caffeine needs it to find the hotspot by its name and reads no location. It is asked by "Save" and never at launch. The join runs in the app and not in the helper: a root daemon cannot get this permission.
  • When it joins: the feature is on, a hotspot is saved, the Mac stays awake for an agent that works, waits for you or finished a moment ago, no stop is in force, Wi-Fi is switched on, and macOS has reported no usable way to the network for 15 seconds of awake time without a break. A hold from the command line, a scheduled job, "stay reachable" and the mode "Always" start no join by themselves. Agent Caffeine never joins while the Mac has a way to the network, and asks once more right before the join, so a network that works is never left for the hotspot. It never switches Wi-Fi on or off.
  • Which network it joins: only one that has the name you saved and takes a WPA2 or WPA3 password, as the hotspot of an iPhone does. An open network with the name of your phone is never joined, however strong its signal is: anyone can set one up. When only such a network is on the air, the status line says "A network with this name is open, so your Mac did not join it".
  • How it joins: a scan for the name of the hotspot, then a join with the saved password, through CoreWLAN, the public framework of macOS. When that does not work it tries again 10, 20 and 40 seconds later and then once a minute, for as long as the agents count. The offline rule runs on unchanged: after its limit (10 minutes by default) the agents count no more, the tries end and the Mac can sleep. The tries of one loss end 30 minutes of awake time after the first one in any case, so an agent with a busy command, or the offline rule on "Never", does not make the Mac scan for ever. A password that the hotspot refused twice is not tried again in the same loss: the next loss, after the Mac was online again, has its tries back, and so has a new save. A join that only timed out is no refused password and is tried again. Every call into macOS has a time limit, and a locked Keychain is not read, so macOS shows no window for it: the try fails and the next one comes on the schedule. After a join the Mac waits 20 seconds or more for the phone to give it a way to the network, and no try joins again while the Mac is on the hotspot.
  • On the hotspot: the Mac stays on it after the agents finished, until you change the network, or until it goes back to its Wi-Fi (see "The way back"). Agent Caffeine never takes the Mac off a network. If you leave the hotspot (or the phone goes out of reach: Agent Caffeine cannot tell which), the Mac does not join it again by itself. The next join needs a loss on another network, or a new "Save".
  • The way back: the Wi-Fi the Mac was on when the way was lost is kept in memory only. While the Mac is on the hotspot, it looks for that network every 2 minutes, and every 10 minutes on battery with the lid closed, because each scan costs charge. The network must be really gone first: when the first look after the join already sees it with a good signal (-70 dBm or better), the loss was no real loss, because you clicked "Disconnect" or the router dropped the Mac, and going back would undo that, so this join gets no way back. When the first look does not see it, and it is then seen with a good signal on two looks in a row, the Mac joins it with no password, since macOS keeps that one. A weaker signal is no way back: at the edge of a network the Mac would go to and fro. This is tried once: when macOS refuses, the Mac stays on the hotspot. Only the network of the moment of the loss counts, read at most 5 seconds before, so a network you left by hand earlier is never joined. Only a network that macOS knows as one with a WPA2 or WPA3 password counts, by its name to the letter: an open network, and one that asks for a user name, get no way back by themselves. Only a join of Agent Caffeine itself has a way back: when you or macOS joined the hotspot, the Mac stays there. A pause, the switch and a safety stop end the way back. Settings has a switch for the way back.
  • What you see: Settings has a status line with the last result ("Joined at 14:02", "Did not find the hotspot at 14:02"), and the menu has one quiet line while the Mac is on the hotspot ("On your iPhone hotspot since 14:02"). The line goes with a pause, the switch and a safety stop, and a few seconds after the Mac is on another network. There is no notification for a join. When no join works, the offline rule and its notification say what became of the Mac.
  • Limits. An iPhone shows its hotspot for about 90 seconds after the last device left, and only macOS itself is known to wake it, over Bluetooth. A scan of Agent Caffeine may find nothing while the phone sits locked in a pocket: this is not measured yet, and neither is a join with the lid closed and the screen locked. From macOS 26 on, "Ask to join hotspots: Automatic" in the Wi-Fi settings lets macOS join the hotspot by itself, and Settings shows whether it is set. When macOS joins it, or you do, Agent Caffeine shows the same line and takes no way back: it did not take the Mac there. On the hotspot everything on the Mac uses the data and the battery of your phone, not only the agent. "Joined" means the Mac is on the hotspot, not that the phone has a way to the internet: Agent Caffeine sends no request to find out. A turn of an agent that failed while the Mac was offline is not started again. Which error macOS gives for a wrong password is not written down by Apple. Only the two errors of the key itself count as a refused password, so a wrong password can show as "Could not join the hotspot" or "macOS gave no answer".
  • Measuring: caffeinagent --hotspot-probe "<name>" [--join] [--seconds N] (the binary in Contents/MacOS of the app) prints once per second whether a scan sees the hotspot, with the signal and the channel, what macOS says about Location Services, whether Wi-Fi is on and whether the Mac has a way to the network. Without --join it only reads. With --join it asks for the password, which is not shown and not stored, and joins the hotspot once. It prints no network name but the one you gave, and it never switches Wi-Fi on or off.
  • The rules are in Caffeinagent/Detection/Network/: HotspotHop.swift (when to join and when to go back), HotspotJob.swift (the scan and the join), HotspotWiFi.swift (which network of a scan may be joined) and HotspotHopLoop.swift (the settings and the loop). The calls into macOS are in Caffeinagent/HotspotSystem.swift.

Privacy

Agent Caffeine makes one kind of network request: it asks caffeinagent.com for updates, once a day and when you press "Check for updates…". It sends nothing about you or your agents, and it has no analytics. What it reads of an agent (its state, the name of its session for the menu) stays on the Mac.

"Join your iPhone hotspot" asks for Location Services only because macOS shows the names of Wi-Fi networks with it. Agent Caffeine reads no location and keeps none. The name of your hotspot is in the settings of the app and its password in your login Keychain. No network name and no password is logged, recorded or sent, and the state for the tests has neither.

Build from source

You need Xcode 16 or later and XcodeGen. The deployment target is macOS 13.

brew install xcodegen          # one time
xcodegen generate              # makes the Xcode project from project.yml
xcodebuild -project Caffeinagent.xcodeproj -scheme Caffeinagent -configuration Debug -derivedDataPath build
open build/Build/Products/Debug/caffeinagent.app

Xcode names the build product caffeinagent.app. scripts/release.sh copies it to Agent Caffeine.app for the disk image, and that is the name of a new install.

A Debug build is signed ad hoc and needs no Apple account. Two things do not work in it, by design: the helper refuses it, so a closed lid on battery and "Wake for jobs" do not work, and the updater never starts. A fork that wants them needs its own Developer ID, bundle ids and update key: CONTRIBUTING.md lists every value to change.

Tests

  • The unit tests: xcodebuild test -project Caffeinagent.xcodeproj -scheme Caffeinagent -destination 'platform=macOS'. They compile the logic files directly and never start the app, so they touch no power setting, no helper and no Keychain.
  • The smoke test: scripts/smoke-test.sh. The unit tests pass on a build that hangs at launch, so no release is made without it. It builds the app and starts it for 14 seconds as caffeinagent --smoke-test 14: the real scenes, the real aggregator and the real engine, with no power assertion, helper, Keychain, Sparkle or notification behind them, and with settings of its own, so it is safe next to the copy you use. It hides and shows the icon and opens Settings once in its first seconds, takes no click, and fails when the main thread stalls, the app uses 15% of a core or more in the median steady second (the seconds after the first 2, without the ones of those two steps) or 40% or more over the steady seconds, the scenes are worked out more than 50 times per second, the menu bar has no icon or the engine takes no sample. For its last 6 seconds it shows the views of the menu in a window that is on no screen, with a robot that floats and agents at work, and fails when the app uses 12% of a core or more in the median of those seconds: the motion of the menu runs in Core Animation and costs the app next to nothing, and a motion that the app works out for each frame takes 20% or more. It prints what it measured as one line of JSON, then PASS or FAIL. scripts/smoke-test.sh --app <path> tests a bundle that is built already, and scripts/release.sh runs it on the signed app and stops when it fails, also with --dry-run.
  • The test runner for the real app: scripts/actest. It guides a person through tests on a real Mac (close the lid, pull the charger, send a prompt to an agent), sees what it can by itself, records, judges and cleans up. It uses no sudo and never changes a power setting. See scripts/actest/README.md. Its own tests run with cd scripts/actest && python3 -m unittest discover -s selftest -t ..

Releases and how they are signed

The app updates itself with Sparkle. It reads the feed at https://caffeinagent.com/mac/appcast.xml once a day, and from "Check for updates…" in Settings. Debug builds never check.

  • scripts/release.sh is the only way to publish a release. It builds, signs, notarizes, writes the dmg, signs it for Sparkle, adds it to the appcast, uploads the dmg, appcast.xml and latest.json to mac/ in the R2 bucket the site serves (named in ~/.config/agent-caffeine/release.env, public to read at https://files.caffeinagent.com). --dry-run does everything local and uploads and notarizes nothing. A release stops at the start when the working tree has changes that are not committed (--dry-run --allow-dirty builds them all the same, and scripts/build-on-request.sh uses that), runs the unit tests before it builds, and asks Gatekeeper (spctl) about the notarized app and the dmg before the first upload. The header of the script lists the one-time setup.
  • The workflow .github/workflows/mac-release.yml runs the same script on GitHub Actions when a push to main raises the build number, after the owner approves the run. "Run workflow" starts a dry run by hand. See "Release" in CONTRIBUTING.md.
  • Updates are signed with the EdDSA key in the login keychain under the account agent-caffeine. Its public half is SUPublicEDKey in project.yml. Every Sparkle tool call passes --account agent-caffeine, or, in the workflow, the same key as a file.
  • On the Mac, run the script from a local Terminal: the keychain refuses SSH and scripted sessions.
  • The app and its helper are signed with the Developer ID of the owner (team 3HGP3W3TLD) and notarized by Apple. The helper takes calls only from an app that this team signed.
  • Each release is built from one commit of this repository, and that commit has the tag of the version (v1.1.0). The GitHub release of the tag links the dmg on the site and gives its sha256.
  • The license of the app, THIRD_PARTY_NOTICES.md and the texts in Resources/Licenses/ are bundled into the app.

Names

The brand is Agent Caffeine, with both words capitalised, in every text people read. The app bundle is Agent Caffeine.app from 1.0.2. It was agent caffeine.app in 1.0.0 and 1.0.1, and an update keeps the name of the bundle it replaces, so both names are installed and both are supported: see AppInstall. caffeinagent is the name of the binary, the bundle id and the domain, and the Xcode target and source folder are Caffeinagent.

License

Copyright (C) 2026 Mert Duzgun

This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, version 3.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.

The full text is in LICENSE. The SPDX identifier is AGPL-3.0-only. The app links to this source code from Settings > About.

  • The small files that Agent Caffeine writes into the folders of your agents (the hook lines, the opencode plugin, the Pi extension and the skill) may also be used under the MIT license, so they put no term of the AGPL on your agent configuration.
  • What the app carries from other people, and under which licenses, is in THIRD_PARTY_NOTICES.md.

Name and icon

The source code is under the AGPL. The name Agent Caffeine, the app icon and the robot are not. A changed version that you give to other people must have another name and another icon. See TRADEMARKS.md.

The agent names and logos are trademarks of their owners and are used only to identify those products.

Contributions: CONTRIBUTING.md. Security reports: SECURITY.md.

↑↓ navigate openesc close