Locked Mac, computer use, stale screen state
When your Mac locks while a computer use agent is running, the agent does not get a black screen. It gets a frozen one. The screenshot looks normal. The accessibility tree looks alive. The click returns success. None of it is real. The agent keeps replaying actions against a buffer that the WindowServer stopped composing minutes ago, and the events it posts land in the password field on the lock screen instead of your target app.
The single check that ends this: read CGSessionCopyCurrentDictionary() from CoreGraphics, look at the boolean at CGSSessionScreenIsLocked, and pause the loop until it clears. Anthropic's reference computer use loop does not do this. Terminator's old macOS port did not do it either, which is part of why that port was deleted on 2025 12 16 and the project ships Windows only today.
Direct answer (verified 2026-05-21)
Three OS layers disagree when the Mac is locked, and the agent loop sees all three at once. The WindowServer has paused frame composition, so any screenshot returns the last rendered buffer from before the lock. The target app's process is still running, so its accessibility tree responds normally with live data. The system event tap is owned by loginwindow, so synthetic clicks and keystrokes go to the password field, not to your app. The check that reconciles all three: read CGSessionCopyCurrentDictionary(), look at the CGSSessionScreenIsLocked boolean, and skip the iteration when it equals 1.
Source: Apple CoreGraphics docs for CGSessionCopyCurrentDictionary. The same boolean is also reachable from launchd or SSH via ioreg -n Root -d1 -a, key path IOConsoleUsers:0:CGSSessionScreenIsLocked.
What is actually frozen, and what is not
The intuition most people start from is wrong. They assume a locked Mac is like a sleeping one. Sleep suspends processes; a lock does not. The right mental model is that lock is a session level overlay, not a system level pause. Three layers respond differently to it.
The first layer is the WindowServer. On a normal session, the WindowServer composites every app's window contents into a shared screen buffer that the display reads from. When the session locks, the loginwindow process takes over what shows on the display, and the per app composition pipeline stops writing into the screen buffer. The buffer keeps its last contents. Anything that reads from it (CGWindowListCreateImage, SCScreenshotManager, the system screencapture binary) gets a frame that looks valid and is not.
The second layer is the app process itself. App processes do not pause when the session locks. The run loop keeps spinning, timers keep firing, network callbacks keep landing, background work keeps progressing. Anything that walks the accessibility tree (AXUIElementCopyAttributeValue and friends) talks to that process directly over Mach IPC. The tree it returns is the real, current state of the app, just invisible to the human looking at the lock screen.
The third layer is the system event tap. When the session is unlocked, posted CGEvents flow through to the foreground app. When it is locked, loginwindow owns the input focus and your synthetic clicks and keystrokes land inside loginwindow's controls (overwhelmingly, the password field). The post call returns success either way. The model sees a tool call result of ok and infers the action landed.
All three layers are doing their normal job. The bug is in the agent loop that assumes they are all talking about the same world.
The four line guard
Most computer use loops on macOS today look like the left side below. Screenshot, tree, model, action, repeat. Nothing in the loop asks the OS whether the screen is even being composed.
The right side is the same loop with a CoreGraphics dictionary read at the top and a before / after comparison around the action. Four functional lines added. The agent now refuses to spend a model turn on a stale frame, and refuses to record an action as successful if the session locked while the action was in flight.
loop with and without the lock guard
# the loop most agents ship today
while task_pending:
screenshot = screencapture() # returns last composited frame
tree = ax_get_tree(target_pid) # process still alive, tree fresh
plan = model.next_action(
screenshot, tree, history
) # model sees a live world
cg_event_post(plan.event) # event routed to loginwindow
sleep(1) # repeat against the same stale frameThe Python form is shown for readability. The actual binding depends on your stack. PyObjC exposes Quartz.CGSessionCopyCurrentDictionary. Swift exposes the same function in the CoreGraphics module. From a shell script you can read ioreg -n Root -d1 -a and grep for CGSSessionScreenIsLocked. All three paths read the same kernel level value.
How the detection actually wires up
There is no public async event for session lock on macOS. You poll. The poll is cheap. Two seconds between reads is plenty, and the call itself is a single Mach round trip plus a dictionary lookup.
- 1
Poll the session dictionary
CGSessionCopyCurrentDictionary returns a CFDictionary describing the current console session. Reading it is a single Mach call, safe to do every two seconds.
- 2
Read CGSSessionScreenIsLocked
The boolean key inside that dictionary. 1 means locked, 0 or absent means unlocked. nil dictionary means no console session is visible, treat as locked.
- 3
Branch the loop
If locked, pause the agent until it clears. Do not spend a model turn on a stale screenshot. Do not post events that will land in the password field.
- 4
Resume on transition
When the value flips back to 0, take a fresh capture and refresh the AX tree before deciding what to do next. The world may have moved while you were paused.
For agents that need event style notification rather than a poll, the closest thing in public API is NSDistributedNotificationCenter with the names com.apple.screenIsLocked and com.apple.screenIsUnlocked. Those are not officially documented and have been broken in at least one macOS release (Sequoia 15.0 dropped them silently for sandboxed apps, restored in 15.1). The polling path is boring, reliable, and has not regressed in years. Pick boring.
Why a fresh screenshot does not fix this
The first instinct from a Twitter thread is usually "just retake the screenshot after a short sleep". That does not work because the screenshot API is reading from a buffer the WindowServer is not refreshing. The frame is stale at the source. You can call screencapture once per second for an hour and get the same image every time. A file timestamp that ticks forward each second on a sequence of identical pixels is what the bug looks like on disk.
The second instinct is "disable sleep with caffeinate -i". That prevents idle sleep, which is a different OS state. Idle sleep suspends processes. Lock does not. The Mac can be wide awake, processes running, and still locked, with the same stale composition behavior. caffeinate is the right fix for overnight agent runs that go silent because the laptop slept; it is not a fix for the locked screen failure mode.
The third instinct is "use a screen capture session that owns the stream". SCStream with a long lived output handler is genuinely a more efficient capture path. It does not change the underlying truth that loginwindow owns the visible display during a session lock. The stream keeps delivering frames; the frames keep showing the same content.
The diagnostic checklist
If you are reading this because an agent ran overnight and you are not sure whether what you saw was the lock bug or something else, these five checks isolate it within a minute or two.
five checks that isolate the lock state failure
- Read CGSessionCopyCurrentDictionary() at the top of every agent iteration, before screenshot or get_tree. If CGSSessionScreenIsLocked is 1, sleep two seconds and continue.
- Snapshot the lock state immediately before and immediately after every action. A transition from 0 to 1 mid action means the action did not land. Mark the result as unknown, not success.
- Treat a nil session dictionary the same as locked. You are running from a context (launchd, SSH, no console session) that cannot see the user's UI either way.
- Do not rely on caffeinate -i. Idle sleep and session lock are different OS layers. caffeinate prevents the first; it does nothing for the second.
- If your loop is running from a process that needs to work across both states (a background daemon), poll CGSSessionScreenIsLocked once every two seconds rather than registering a notification. CoreGraphics has no public async lock event; the polling cost is one Mach call.
Why Terminator ships Windows only today
The honest disclosure: the lock state mismatch above was one of several reasons the Terminator team deleted the macOS port. The full deletion commit is 0c11011c on 2025 12 16. It removed crates/terminator/src/platforms/macos.rs (4,368 lines) and crates/terminator/src/platforms/linux.rs (2,962 lines) in one stroke. The macOS file never shipped a CGSSessionScreenIsLocked guard. Neither did its sibling problem (AXPress no oping on Chromium web views), neither did the third sibling (CGEvent fallbacks taking the OCR detour the AX path was meant to avoid). At some point a half working cross platform story becomes worse than an honest single platform one.
On Windows, the equivalent failure mode is much louder. The session lock there is gated through WTSRegisterSessionNotification with a real WM_WTSSESSION_CHANGE message, and UIAutomation calls against a locked desktop fail with E_ACCESSDENIED rather than silently returning stale data. An agent loop can branch on the error code. The bug class this page describes is structurally harder to introduce on Windows.
If you are on macOS today and want a structural alternative to screenshot loops, the practical options are: the Anthropic reference loop plus your own CGSSessionScreenIsLocked guard, a local PyObjC wrapper around the AX APIs, or a remote Windows box that Terminator drives. The Mac story is genuinely worse than the Windows story for computer use right now, and pretending otherwise has cost the open source ecosystem a lot of debugging hours.
Building a computer use loop that needs to survive a locked screen?
Half an hour with the team. We will walk through your loop, point at the lock guards (and three other macOS sharp edges), and tell you honestly whether your target use case is better served by a Mac with custom guards or a Windows runner with Terminator.
Frequently asked
Frequently asked questions
Why does my screenshot still return a normal looking screen when the Mac is locked?
Because that is not a fresh capture, it is the last frame the WindowServer composited before the lock. When the session locks, macOS routes display output to the loginwindow process and stops the per app composition pipeline that fills the screen buffer with your normal windows. Screen capture APIs (CGWindowListCreateImage on older systems, ScreenCaptureKit on macOS 14 and later) read from that buffer. If the buffer has not been redrawn, they hand you the old contents. The image looks correct, the timestamp on the file is current, the pixels are stale. There is no error code. The capture just lies politely.
If the screen is frozen, why does my agent's get_tree call still return a live looking accessibility tree?
Because the AX tree of a process lives in that process, not in the WindowServer. AXUIElementCopyAttributeValue queries the target app over Mach IPC. The target app's run loop keeps spinning while the screen is locked (lock does not pause processes the way sleep does), so its UI state advances normally. The button states, text field contents, focused element, and window hierarchy all keep updating. If something off screen changed (a download finished, a websocket dropped, a timer fired and opened a modal), the AX tree reflects it. The agent reads that tree and decides what to do based on a world that genuinely exists but is invisible to the human looking at the lock screen.
What happens to a synthetic click or keystroke when the Mac is locked?
It posts. CGEventPost(.cgSessionEventTap, event) returns success. The event enters the system event stream. The system event stream, when the session is locked, is owned by loginwindow. Your keystrokes go into the password field. Your mouse clicks hit whatever loginwindow renders on top. The target app behind the lock screen receives nothing for those events, though it can still receive events generated by its own code. Net result: the AX tree says 'I clicked, nothing happened in the app', the screen is frozen on a frame from five minutes ago, and the model on the next iteration sees an unchanged scene and concludes the click failed. It retries. It retries louder. It types its own intermediate plan into your password prompt.
Doesn't caffeinate fix this?
No. caffeinate -i prevents idle sleep, which is a different system state. Idle sleep suspends processes. Lock leaves processes running and just covers the screen. You can prove this in a terminal: run caffeinate -i in one window, lock the Mac with Ctrl Cmd Q, take a screenshot from a second SSH session, observe the stale frame. The two assertions live in different OS layers. Sleep is a power state. Lock is a session state. Most agent guides confuse them because both lead to 'agent stopped working', but the fix for one does nothing for the other.
What is the canonical lock check?
Call CGSessionCopyCurrentDictionary() from CoreGraphics, read the value at key CGSSessionScreenIsLocked. If the dictionary is nil (no session) or the key is missing, treat the session as unsafe. If the key is present and the value is 1, the screen is locked. If the value is 0 or absent, the screen is unlocked. The same boolean is reachable from the IOKit registry at IOConsoleUsers:0:CGSSessionScreenIsLocked, which works from a non console process and from launchd jobs. There is no async event API for this in public CoreGraphics. You poll. Two seconds between polls is reasonable; the cost is one Mach call and a dictionary lookup.
How often does this hit a real agent in practice?
Every time the user walks away. The Mac defaults to require password after sleep or screen saver begins, and many users have screen saver firing inside five minutes. An overnight agent run that the user kicked off and walked away from will run for about three to ten minutes, then enter this stale state, then keep running on a frozen world for hours. The cost of the bug is not visible until the user comes back, unlocks, and sees that the last ten thousand tool calls were operating on the same screenshot.
Why does Terminator only ship Windows binaries today?
Because the cross platform story on macOS was costing more than it was returning. The macOS implementation was a real Rust file (crates/terminator/src/platforms/macos.rs, 4,368 lines) until commit 0c11011c on 2025 12 16, when it was deleted alongside the Linux port (another 2,962 lines). Lock state was one of several issues, not the only one. AXPress and AXClick silently no op on every Chromium based browser. Bundled web views inside Slack, Notion, and VS Code expose the same AX hole. Synthetic CGEvent fallbacks bring back the OCR problem the AX path was supposed to avoid. The pragmatic decision was to ship one platform well rather than two platforms with known footguns. Windows UIA does not have the lock equivalent failure because UIAutomation calls on a locked Windows session fail loudly with E_ACCESSDENIED, which an agent loop can branch on.
Can I still use Terminator on macOS in some form?
Not as a binary today. The Rust core compiles on macOS for tests, but the public packages (npm @mediar-ai/terminator, pip terminator-py, the MCP agent) ship Windows binaries only. If you are running computer use on a Mac and want a structural alternative to screenshot loops, your options are the Anthropic reference loop plus your own CGSSessionScreenIsLocked guard, Apple's own AX APIs via PyObjC, or a remote Windows VM that Terminator drives. The honest answer is that nobody has shipped a great Mac equivalent yet; the public macOS automation surface is genuinely worse than UIA on Windows.
Where in my agent loop should the lock check live?
Two places. First, at the top of every iteration, before the screenshot or get_tree call. If locked, sleep two seconds and continue without spending a model turn. Second, around any action call, comparing the lock state immediately before and immediately after. A transition from unlocked to locked mid action means the action almost certainly did not land where you expected, even if the API returned success. You should mark the action as 'unknown outcome' and re plan, not 'succeeded' and proceed.
Is the stale frame the same content every time?
Yes, until something forces the WindowServer to redraw. The redraw is triggered by user input on the lock screen (typing a password, clicking the cancel button), by the screen waking from display sleep, or by certain notifications that animate in over the lock screen. Without one of those, you can wait an hour and the buffer state does not change. This is why the bug is so cleanly visible: an agent making fifty tool calls per minute against a static frame will produce a near identical chain of model calls until lock clears. The transcripts are eerie.
Related, on the same kind of edge case
Computer use agent state tracking: why a 118 pixel window shift returns zero diff
How Terminator returns the accessibility tree diff inside the action response so the agent does not have to spend a model turn comparing screenshots.
macOS accessibility UI tree automation: the write path nobody warns you about
AXPress and AXClick return success on browser views and do nothing. Notes from a 4,368 line macOS implementation that got deleted on 2025 12 16.
Accessibility tree closes the browser to native gap
Why structural element resolution beats pixel matching across Windows UIA and macOS AX, and where the abstraction leaks between the two.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.