I Asked My Agent to Teach Me Node.js. It Gave Me Homework Instead
The story of MonitorX: how building a tiny process manager taught me child processes, daemons, unref(), and streaming logs over a Unix socket.
I knew Node.js the way most backend developers know Node.js: routes, handlers, npm install, ship it.
Then one evening I started poking at the process global - the thing that’s just there in every file, holding process.pid, process.argv, process.env - and realized I had no real mental model of what a process actually is.
So I did what I always do now: I had a long conversation with my agent about it. I expected a tidy explanation. Instead, somewhere around the third “okay, but why”, it stopped explaining and said, roughly: you should build a process manager. A small pm2. You’ll stop asking these questions because you’ll have to answer them.
That homework became MonitorX, a lightweight Node.js process manager.
This post is the story of building it, told through the three ideas that rearranged my head: child processes, unref(), and streams.
Chapter 1: Every process is somebody’s child
The first thing you learn is that your terminal, your editor, and your Node script are all just entries in one big family tree. When your Node program starts another program, that program becomes its child, and Node gives you a handle to it:
import { spawn } from 'node:child_process';
const child = spawn('node', ['server.js']);
child.stdout.on('data', (chunk) => console.log(`[child] ${chunk}`));
child.on('exit', (code) => console.log(`child died with code ${code}`));
Three things clicked here, in order.
First, stdout on a child is not a string, it’s a stream.
The child writes whenever it wants, your parent gets data events whenever the OS feels like flushing them, and nobody waits for anybody.
Coming from PHP, where output is something you collect at the end, this was the first real shift.
Second, spawn vs fork finally made sense.
spawn runs any command - it’s the general tool.
fork is spawn specialized for Node scripts, and it opens an extra IPC channel so parent and child can send each other messages.
MonitorX ended up using both, for reasons coming in Chapter 2.
Third, and most important: the parent-child relationship is a leash.
When I killed my parent script, the children I’d spawned died with it.
For a process manager, that’s a fatal flaw.
The entire point of monitorx start server.js is that your server keeps running after you close the terminal.
So the real question of the project revealed itself: how do you start a process that outlives you?
Chapter 2: unref(), or how to let go of your children
The answer has a name that sounds like a mistake: you daemonize.
A daemon is a process with no terminal, no parent watching it, running quietly in the background - the d in sshd and systemd.
Node can do this, but you have to ask for three specific things at once. Here is the actual code from MonitorX’s daemon entry point, only lightly trimmed:
if (process.argv.includes('--daemonize')) {
const child = spawn(process.execPath, [process.argv[1]], {
detached: true, // put the child in its own process group
stdio: 'ignore', // no shared stdin/stdout pipes with the parent
});
child.unref(); // let the parent exit without waiting
process.exit(0); // parent leaves; child stays
}
The process re-launches itself as a detached copy, then immediately exits. The copy lives on with no terminal attached. It took all three flags to make that work, and each one taught me something:
detached: trueputs the child in its own process group, so closing the terminal (which signals the parent’s group) doesn’t reach it.stdio: 'ignore'cuts the pipes. If the child still shared stdout with the parent, that open pipe alone would keep them tied together.unref()is the one I’d never heard of, and my favorite.
Here’s the thing unref() actually does, because I got it wrong at first.
It does not detach the child - detached does that.
Node’s event loop keeps the process alive as long as anything is still referenced: a timer, a server, or a child process handle.
A spawned child counts as a reference, so the parent would sit there waiting for the child to exit before it could die.
unref() tells the event loop: stop counting this one. If it’s the only thing left, you’re free to go.
Who restarts the restarter?
A process manager restarts your app when it crashes. Cool. What restarts the process manager when it crashes?
MonitorX’s answer is that the daemon is actually two processes: a tiny watcher, and a worker that does all the real work.
The watcher forks the worker and does exactly one job:
function spawnWorker() {
worker = fork(daemonScript, ['--is-worker'], { stdio: 'inherit' });
worker.on('exit', (code) => {
if (!shuttingDown && code !== 0) {
console.error(`Worker crashed with code ${code}. Restarting...`);
setTimeout(spawnWorker, 1000);
}
});
}
If the worker dies with a non-zero code, the watcher waits a second and forks a new one. The watcher itself is so small that it has almost nothing left in it that can crash. I later learned this is an old, honorable pattern - supervisors in Erlang, the shepherd/sheep split in every init system - but I got to rediscover it by needing it, which is a very different feeling from reading about it.
Chapter 3: Logs are just streams looking for a home
monitorx logs was the feature I assumed would be trivial and wasn’t.
Think about what it actually requires: a process started yesterday by a daemon you can’t see, and a CLI you open today that wants to watch that process’s output live.
The two halves of the answer:
Half one: the daemon hoards output. Every managed process gets its stdout and stderr piped into an in-memory buffer, capped so a chatty process can’t eat the daemon’s RAM:
const handleOutput = (data: Buffer) => {
state.buffer.append(data);
if (state.buffer.length > MAX_BUFFER) {
state.buffer.consume(state.buffer.length - MAX_BUFFER); // drop oldest
}
for (const sub of state.subscribers) {
sub.write(JSON.stringify({ type: 'LOG', id, data: data.toString() }));
}
};
state.child.stdout?.on('data', handleOutput);
state.child.stderr?.on('data', handleOutput);
Append new data, trim the front when it overflows - a poor man’s ring buffer. New subscribers get the buffered history first, then live output as it happens.
Half two: the CLI is just another client.
The CLI never touches your processes directly.
It connects to the daemon over a Unix domain socket - a file at ~/.monitorx/daemon.sock that works like a network socket without the network - and speaks a protocol of JSON messages separated by newlines.
Every command is one JSON line; every response is one JSON line back.
This split had a payoff I didn’t design on purpose: when you hit Ctrl+C during monitorx logs, you kill the CLI, and the daemon just removes that socket from its subscriber set.
Your server never notices.
The leash from Chapter 1 is gone because the CLI was never the parent - it was only ever a spectator.
The chapters I’m skipping
There’s more in MonitorX that deserves its own post: cluster mode, where the daemon uses Node’s cluster module to run N workers of your server sharing a single port with zero code changes, and auto-restarts any worker that crashes (while carefully not resurrecting ones you stopped on purpose - a flag ordering bug I found the fun way).
And state persistence, where the daemon writes its process table to disk so everything comes back after a reboot.
But the heart of the project is the three ideas above.
What the homework was actually about
Looking back, my agent’s move was better teaching than any explanation would have been. “What is a process?” has a Wikipedia answer. Building MonitorX forced me through the version of the question that sticks: what keeps a process alive, what kills it, who its parent is, and what its stdout is actually connected to.
The pattern I took away: when a concept won’t stick, don’t find a better explanation - find a project that’s impossible to finish without understanding it. The concept then stops being trivia and becomes a load-bearing wall in something you built.
If you want to poke at the result: npm install -g @yamanzyan/monitorx, then monitorx start server.js.
Close your terminal.
Your server won’t care - and now you know exactly why.