A statusline sounds like a weekend project. Read some JSON, print one colored line, done.
Then you run it on Windows.
I built agy-powerline, a statusline for Google's Antigravity CLI (agy). It's plain Node with no dependencies, and it runs on Windows, macOS, Linux and WSL. Most of the work wasn't drawing the bar. It was making sure the bar starts, every time, on every machine, and never takes the session down with it. Here's what I ran into.
How agy runs a statusline
You give agy a command in its settings.json:
{
"statusLine": {
"type": "command",
"command": "C:/Users/you/.agy-powerline/statusline.cmd"
}
}
Whenever its state changes, agy starts that command, writes a JSON payload to its stdin (model, quotas, context window, terminal width and more), and shows what it prints.
The official docs explain the payload and show a bash script you chmod +x. They don't say how the command is started on each OS, how long it may take, or what happens when it fails. Everything below I found by testing.
Two details matter a lot:
- On Windows,
agyruns the command throughcmd /c. On macOS and Linux, throughsh -c. - It runs all the time. Anything slow, fragile or noisy shows up right away.
Trap 1: quotes that cmd doesn't understand
The obvious Windows command is "C:\Program Files\nodejs\node.exe" "C:\Users\you\...\statusline.js". It doesn't work.
agy is written in Go, and Go's os/exec escapes embedded double quotes as \" when it builds the Windows command line. That's the C runtime's convention, but cmd doesn't follow it: it keeps the backslash and the quote as part of the program name, and can't find node.exe.
So the setting holds no quotes at all. It points at a small launcher, written with forward slashes:
@echo off
setlocal DisableDelayedExpansion
"C:\Program Files\nodejs\node.exe" "%~dp0statusline.js"
The quoting now happens inside the batch file, where cmd handles it the normal way. A few details:
-
%~dp0is the launcher's own folder, so the script path is never written twice. - A
%in the Node path is written as%%, orcmdwould read it as a variable. -
DisableDelayedExpansionkeeps a!in a path from being expanded.
cmd also splits a command line at &, (, ^, ,, ; and =. A launcher in a folder whose path contains one of them never starts, so init refuses such a folder and says how to pick another (AGY_POWERLINE_HOME). An error at install time beats a bar that silently never appears.
Trap 2: the right Node, the right shell quoting
On macOS and Linux the command is sh -c, so the paths are single-quoted, which keeps spaces and $ intact:
'/opt/homebrew/bin/node' '/Users/you/.agy-powerline/statusline.js'
The Node path is process.execPath, the binary that ran init, not node from the PATH. agy may not start with your shell's PATH (with nvm, for example), so a bare node can resolve to nothing, or to an old system Node.
WSL had its own version of this. Windows' npx is often on the WSL PATH through /mnt/c/..., and running init with it would write a Windows Node path into Linux settings. init and doctor detect a Windows Node under WSL and refuse to continue.
Trap 3: the invisible BOM
Windows PowerShell 5.1 writes UTF-8 files with a byte order mark when you ask for UTF-8 (Set-Content -Encoding UTF8, Out-File). JSON.parse doesn't skip it:
JSON.parse("\uFEFF{}"); // SyntaxError
So anything that reads JSON strips it first: agy's settings.json, the bar's config, imported configs, and the payload on stdin.
const text = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
One line, but missing it means a config the user edited in the wrong editor breaks the bar for no visible reason.
Trap 4: a stdin that never closes
This one took a while to find. Sometimes agy starts the statusline and keeps its stdin open without writing anything (at the time of writing it's open as google-antigravity/antigravity-cli#698). A script that does a blocking read of stdin waits until agy kills it. After a few kills in a row, agy turns the custom statusline off.
The fix on the script side: read stdin asynchronously, with a deadline.
function readStdin(timeoutMs) {
return new Promise((resolve) => {
const chunks = [];
let done = false;
const finish = (complete) => {
if (done) return;
done = true;
clearTimeout(timer);
// An open stdin would keep the process alive after the bar is printed.
process.stdin.destroy();
resolve({ text: Buffer.concat(chunks).toString("utf8"), complete });
};
const timer = setTimeout(() => finish(false), timeoutMs);
process.stdin.on("data", (chunk) => chunks.push(chunk));
process.stdin.on("end", () => finish(true));
process.stdin.on("error", () => finish(true));
});
}
With a 1-second deadline: if stdin closed, render it. If it didn't and nothing arrived, exit 0 with no output. The script is never killed, so it's never counted as a failure.
Trap 5: never crash where the bar goes
Whatever the statusline prints ends up in the status bar. A stack trace there is ugly at best, and can quote payload text at worst. So the runtime has a few hard rules:
- An uncaught error prints a short fixed marker (
[agy-powerline: error, see agy-powerline.log]), writes the details to a local log, and exits 0. - Invalid JSON prints
[agy-powerline: invalid JSON]and says where it broke, without quoting the input. - When
agycloses the pipe before the bar is written (EPIPE), that's a normal shutdown, not a crash: exit quietly. - Text that isn't ours (branch names, the conversation title, command output) is stripped of terminal escape sequences before it's printed.
- The payload holds personal data: your email address, the conversation ID and the path to the transcript. None of it is printed, logged or written to the debug dumps users attach to bug reports.
Start-up time is most of the cost
A statusline is a new process on every refresh, so start-up time matters more than the rendering. On a Windows 11 laptop, agy-powerline's own work is about 0.1 s per refresh. Starting cmd and Node adds about 0.1 to 0.25 s, depending on how busy the machine is.
Two things made a measurable difference:
-
One file instead of eighteen. On Windows, Node spent more time opening the 18 small module files than running them.
initnow joins the runtime into a singlestatusline.js(each file still gets its own scope, the way Node wraps modules). That cut agy-powerline's share of each refresh by about a third. -
No
gitprocess unless needed. The branch and dirty state come from reading.gitdirectly. The ahead/behind counts needgit rev-list(about 60 ms on Windows), so they're cached per pair of commits and recomputed only when the branch or its upstream moves, with a 150 ms timeout.
Fitting any terminal width
The payload includes terminal_width, and the bar has to fit it. When a row is too wide, it shrinks in steps:
- Widgets drop their labels and keep only their values, then the model name and the meters get shorter.
- Widgets drop out by priority (a custom badge goes before the conversation title, the title before the quotas, the model last).
- As a last resort, the row is cut with
….
Measuring "too wide" is its own small problem. You strip escape sequences first, then count columns: emoji are two wide, combining marks and variation selectors are zero. And terminals don't always agree with each other on emoji width, so a little slack helps.
Never lose the user's settings
init edits a file that belongs to another program, so it's careful about it:
- It refuses to touch a
settings.jsonthat isn't plain JSON (comments, trailing commas), and tells you what to add by hand. - It backs up the file before every change, and keeps the very first version in a separate backup that's never replaced (
settings.json.agy-powerline.original.bak). - It only changes the
statusLinekey. - A
settings.jsonsymlinked by a dotfile manager (stow, chezmoi, yadm) stays a link: the change goes into the file it points to. -
uninstallbacks the file up too and removes only thestatusLinekey.uninstall --deletealso deletes agy-powerline's files, but only the ones in a fixed list (INSTALLED_FILES), never a whole folder.
How it's tested
- CI runs on Windows, macOS and Linux, across Node 20, 22 and 24.
- On the Windows runner, a smoke test runs
initin a home folder with a space in its path, then starts the command it wrote through a realcmd.exe /d /c, the way agy does. It fails on a non-zero exit code, on anything written to stderr, or if the bar didn't render. - Snapshot tests pin the rendered bar for each layout and fixture payload, with the clock, time zone and terminal width frozen.
- Property-based tests with
fast-check(a fixed seed, 300 runs per property) throw random and malformed payloads at the renderer: it must never throw, print no control characters besides its own colors, and fit every row into widths from 40 to 240 columns. Another one pipes random input into the runtime script, which must exit 0 without a stack trace.
What I'd tell someone writing a statusline
- Test on Windows on day one. Most of these traps don't exist on macOS.
- Read stdin with a deadline.
- Exit 0, always. Print nothing rather than an error.
- Measure start-up, not just rendering.
- Kill what you start. A timeout that stops waiting for a child process doesn't always stop the process: on Windows, a command run through
cmdcan outlive its timeout. Kill the whole process tree. (That one is still on my own list, for the bar's shell-command widget.) - Treat the payload as untrusted text, and keep its personal fields out of your logs.
- Never start
agyitself, or anything slow, from the statusline. One user on Google's forum pointed it atagyand ended up with about 1,000agy.exeprocesses.
The code is MIT on GitHub: wassim-azi/agy-powerline. If you want to try it out on Windows, macOS, or Linux, you can install it manually:
npx agy-powerline init
Or have your agent install it directly in an open agy session:
"Hey agy, please install agy-powerline by running
npx agy-powerline init --yes, then tell me to restart agy."
If you've hit other Windows traps with Node CLIs, I'd like to hear about them in the comments.

Top comments (0)