At the end of most days I have 15+ apps open. I wanted one command that closes them all, except the terminal I'm typing in, without losing unsaved work.
killall and taskkill /f are out: they kill processes, and unsaved documents die with them. What I wanted was the same thing as pressing Cmd+Q or clicking the X on every app: a polite request the app can answer with "Save changes?".
So I built lightsout, a small Go CLI. This post covers how "quit politely" works on each OS, and the bug that taught me to stop trusting green tests.
The rules
- Only close apps a person would see as "open": Dock apps, taskbar apps, windows on screen. No background daemons.
- Never force-kill. Send the normal close request, wait up to 10 seconds, and report anything still open instead of killing it.
- Keep the terminal you run it from, plus any apps listed in
~/.config/lightsout/config.yml.
macOS: NSWorkspace through JXA
macOS already knows which apps are "regular" ones: every NSRunningApplication has an activationPolicy, and 0 (.regular) means it shows in the Dock. JavaScript for Automation (JXA) can read this through osascript without any Automation permission:
ObjC.import("AppKit"); const apps = $.NSWorkspace.sharedWorkspace.runningApplications; const out = []; for (let i = 0; i < apps.count; i++) { const a = apps.objectAtIndex(i); out.push({ name: ObjC.unwrap(a.localizedName) || "", pid: Number(a.processIdentifier), policy: Number(a.activationPolicy), }); } JSON.stringify(out); To quit, lightsout looks the app up by PID (two apps can share a name) and calls terminate, which is exactly what Cmd+Q sends:
const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(pid); app.terminate; Finder is skipped by its bundle ID and handled separately: its windows get closed, but Finder itself stays running.
Linux: wmctrl + xprop
On X11 there's no Dock to ask, so lightsout works with windows instead:
wmctrl -l # every managed window xprop -id <id> WM_CLASS _NET_WM_WINDOW_TYPE # which app, and what kind of window wmctrl -i -c <id> # close it gracefully Windows whose type is _NET_WM_WINDOW_TYPE_DESKTOP or _DOCK (your wallpaper and panels) are skipped. wmctrl -c sends the same close request as the window's X button. Then lightsout waits for the windows to disappear.
The catch: on Wayland, apps can't see other apps' windows by design, so only XWayland apps are visible. lightsout warns you about this instead of failing silently.
Windows: EnumWindows with the taskbar's rules
Windows has no "list taskbar apps" API, but the taskbar follows well-known rules, so lightsout applies the same ones to every top-level window from EnumWindows:
func onTaskbar(w window) bool { if !w.Visible || w.Title == "" || w.Cloaked || w.OwnConsole || shellClasses[w.Class] { return false } return (!w.Owned && !w.ToolWindow) || w.AppWindow } - Cloaked (from
DwmGetWindowAttribute) catches windows on other virtual desktops and suspended Store apps. - shellClasses skips the desktop and taskbar (
Progman,WorkerW,Shell_TrayWnd). - OwnConsole keeps the console lightsout itself is running in.
Names come from the exe's version info (FileDescription, e.g. "Google Chrome"). Store apps all run inside ApplicationFrameHost.exe, so for those the window title is used instead.
Closing is PostMessageW(hwnd, WM_CLOSE, 0, 0), the same as clicking X, followed by polling IsWindow until the window is gone or 10 seconds pass. No TerminateProcess anywhere.
The bug my tests didn't catch
Every unit test passed. CI was green on all three OSes. Then I ran lightsout on my Mac and it quit nothing.
The JXA bridge returns activationPolicy as a string, so my policy === 0 filter never matched and every app was skipped. My tests fed the parser hand-written JSON with real numbers in it, so they were testing my assumption, not macOS.
The fix was one Number() call. The real lesson: for code that talks to the OS, a mock only tests what you think the OS does.
So CI now runs real end-to-end tests:
- Windows runner: start Notepad, check lightsout lists it, close it with the real code, check the window is gone.
- Ubuntu runner: start Xvfb + openbox, open an xterm, same checks with the real wmctrl/xprop code.
Each takes under half a second, and both are gated behind an env var so they never open windows on your own machine when you run go test.
Try it
brew install abhisheksinha1506/tap/lightsout # macOS / Linux lightsout On Windows, download the zip from Releases and put lightsout.exe on your PATH.
It's MIT-licensed and a single binary. If it fails to close something on your machine, please open an issue. Those reports are exactly what I need.

