Skip to content
← Journal
8 min readAta Mohammadi

Building a Personal AI OS: File Watchers, Local IPC and Daemonised Agents

A terminal agent is a tool you visit. An AI OS is a set of small daemons that react to things happening on your machine. The difference is not the model — it is the event bus, the queue, and the discipline to keep every piece boring.

Most people use AI agents the way they use a calculator: open it, ask, close it. The tool is excellent and entirely passive. Nothing happens unless you initiate it.

The obvious next step is agents that react to your machine instead of waiting for you — a repository changes and the changelog is drafted; a screenshot lands in ~/Desktop and gets named properly; CI fails and the triage is already written by the time you look.

That is a systems project, not an AI project. The model is a subroutine. Everything that makes it work or fail is plumbing that has existed for decades, and the failure modes are the classic ones: a queue that loses work, a daemon that dies silently, a watcher that fires forty times for one save.

The shape

Four layers, each of which should be independently boring.

┌────────────────────────────────────────────┐
│ Sensors      file watchers · git hooks ·   │
│              webhooks · schedules · CLI    │
└────────────────┬───────────────────────────┘
                 │  events
┌────────────────▼───────────────────────────┐
│ Bus          durable queue (SQLite)        │
│              dedupe · debounce · priority  │
└────────────────┬───────────────────────────┘
                 │  jobs
┌────────────────▼───────────────────────────┐
│ Workers      sandboxed agent runs          │
│              one job, bounded, killable    │
└────────────────┬───────────────────────────┘
                 │  results
┌────────────────▼───────────────────────────┐
│ Effects      notify · write file · open PR │
│              — never silent, always logged │
└────────────────────────────────────────────┘

The single most important design decision is the second layer. Everything goes through one durable queue. Not "the watcher calls the agent". A watcher that invokes an agent directly gives you no backpressure, no deduplication, no retry, no record of what happened, and a fork bomb the first time a build writes a thousand files.

Sensors: the hard part is not watching, it is debouncing

A file watcher is easy to start and hard to make quiet. Saving one file in an editor can produce several events — a temp file, a rename, a chmod. A npm install produces tens of thousands. A build produces its whole output directory.

Three rules, learned the expensive way:

  1. Debounce by a coalescing key, not per path. "Repository X changed" is one event with a 3-second trailing window, no matter how many files moved.
  2. Ignore aggressively, by default. .git, node_modules, dist, .next, target, anything in .gitignore. Start from a deny-all posture and allow what you care about.
  3. Ignore your own writes. The first thing an effect-producing agent will do is trigger itself. Tag writes made by the system and filter them at the sensor.
import { watch } from "node:fs";
import { enqueue } from "./bus.mjs";

const IGNORED = /(^|\/)(\.git|node_modules|dist|build|\.next|target)(\/|$)/;
const WINDOW_MS = 3000;
const pending = new Map(); // coalescing key → timer

export function watchProject(root, project) {
  watch(root, { recursive: true }, (_event, filename) => {
    if (!filename || IGNORED.test(filename)) return;

    const key = `project:${project}:changed`;
    clearTimeout(pending.get(key));
    pending.set(
      key,
      setTimeout(() => {
        pending.delete(key);
        // One job per quiet period, regardless of how many files moved.
        enqueue({ kind: "project-changed", project, dedupeKey: key });
      }, WINDOW_MS),
    );
  });
}

Note what is not here: no model call, no analysis, no decision. A sensor's entire job is to turn something that happened into a well-formed event and hand it over. Sensors that think are sensors that hang.

The bus: SQLite, because it is already durable

You do not need Redis, and you certainly do not need a message broker. You need a table.

CREATE TABLE IF NOT EXISTS jobs (
  id          INTEGER PRIMARY KEY,
  kind        TEXT    NOT NULL,
  payload     TEXT    NOT NULL,
  dedupe_key  TEXT,
  state       TEXT    NOT NULL DEFAULT 'queued',  -- queued|running|done|failed
  attempts    INTEGER NOT NULL DEFAULT 0,
  created_at  INTEGER NOT NULL,
  started_at  INTEGER,
  finished_at INTEGER,
  result      TEXT
);

-- At most one queued job per dedupe key: ten saves in a minute is one job.
CREATE UNIQUE INDEX IF NOT EXISTS jobs_dedupe
  ON jobs (dedupe_key) WHERE state = 'queued';

CREATE INDEX IF NOT EXISTS jobs_ready ON jobs (state, created_at);

Turn on WAL mode and it handles concurrent readers and one writer without ceremony. Claiming a job is a single statement:

UPDATE jobs SET state = 'running', started_at = unixepoch(), attempts = attempts + 1
WHERE id = (SELECT id FROM jobs WHERE state = 'queued' ORDER BY created_at LIMIT 1)
RETURNING *;

The partial unique index is the load-bearing line. It gives you deduplication as a database constraint rather than as application logic that will eventually have a race in it.

The durability matters more than it sounds. Machines sleep. Laptops close mid-job. A queue in memory loses everything; a queue in SQLite is exactly where you left it, including the job that was running when the lid closed — which you reclaim on startup by resetting stale running rows past a timeout.

Workers: one job, bounded, killable

for each claimed job:
  - build the smallest context that answers it
  - run the agent in a sandbox, with a wall-clock timeout
  - validate the output against a schema
  - apply effects, or record why not
  - record the result, always

Four constraints, each of which exists because of a specific way this goes wrong:

  • One job at a time per project. Two agents editing the same repository concurrently is not parallelism, it is a merge conflict you generated yourself.
  • A hard wall-clock timeout. An agent has no sense of "too long". Kill at five minutes and record a timeout; a killed job is information, a hung job is not.
  • Structured output, validated. The worker should parse the agent's result against a schema and fail closed. Prose that has to be interpreted is not an interface.
  • Every run is logged, including no-ops. "The agent looked and decided nothing needed doing" is the most common outcome and the one you most need visibility into, because its absence is indistinguishable from a broken daemon.

Keeping the daemons alive

Do not write a supervisor. The operating system has one.

On macOS, a user LaunchAgent at ~/Library/LaunchAgents/com.you.aios.plist:

<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
  <key>Label</key>              <string>com.you.aios</string>
  <key>ProgramArguments</key>
  <array>
    <string>/usr/local/bin/node</string>
    <string>/Users/you/.aios/daemon.mjs</string>
  </array>
  <key>RunAtLoad</key>          <true/>
  <key>KeepAlive</key>          <true/>
  <key>StandardOutPath</key>    <string>/Users/you/.aios/logs/out.log</string>
  <key>StandardErrorPath</key>  <string>/Users/you/.aios/logs/err.log</string>
</dict>
</plist>

On Linux, a systemd user unit with Restart=always and RestartSec=5.

Both give you restart-on-crash, start-at-login, and log files — the three things you would otherwise write badly yourself. And both make "is it running?" a single command, which matters at 9am when you are wondering why nothing happened overnight.

A control plane on a Unix socket

You want to talk to the daemon: check status, queue a job, tail results. A Unix domain socket is the right primitive — filesystem permissions are the access control, there is no port to collide, and nothing is reachable from the network by construction.

import { createServer } from "node:net";
import { chmodSync } from "node:fs";

const SOCKET = `${process.env.HOME}/.aios/control.sock`;

const server = createServer((socket) => {
  socket.on("data", async (buffer) => {
    const request = JSON.parse(buffer.toString());
    const response = await handle(request); // status | enqueue | logs | pause
    socket.end(JSON.stringify(response));
  });
});

server.listen(SOCKET, () => {
  // Owner only. The socket is the whole authorisation model.
  chmodSync(SOCKET, 0o600);
});

Then the CLI is thin: aios status, aios run changelog --project altixcode, aios pause. pause is not optional — you will want a single command that stops everything while you do something delicate, and you will want it to work when you are already annoyed.

The rules that keep this from hurting you

This system watches your files, runs models over your work, and takes actions on your machine, continuously, without you watching. That deserves explicit limits.

Propose by default; apply only on an allowlist. The default effect is a notification and a diff in a scratch directory. Auto-apply is opt-in per job kind, and the list is short: formatting, renaming downloads, drafting a message you will still send by hand.

Never operate on a dirty working tree. If the repository has uncommitted changes, queue the job and skip it. An agent that rewrites work you have not committed is a bad day that no amount of "it was usually right" makes up for.

Never push, never publish, never send. The last step of anything outward-facing is a human. This is the same boundary as in the CI article earlier in this series, and it holds for the same reason: those actions are hard to reverse and the cost of being wrong is asymmetric.

Cap spend, visibly. A daemon that calls a model on every file change can run all night. Set a daily token budget, enforce it in the worker, and surface the number in aios status.

Log everything, and read the log occasionally. The point of failure in a system like this is not a dramatic error; it is a watcher that quietly stopped firing three weeks ago and you never noticed because the absence of output looks exactly like nothing needing done.

Is it worth building?

Honestly: only for a handful of jobs. Most of what people imagine automating this way turns out to be either too rare to justify the plumbing or too consequential to automate.

The ones that have genuinely earned their place are narrow and repetitive: drafting the changelog from a day's commits, triaging a failed build, filing the screenshots, converting a voice memo into a task with the right project attached. Each is a thing you did badly because it was boring.

Which is the honest summary of the whole idea. The AI OS is not an assistant. It is cron with better pattern matching — and cron, wired to the right events, with a durable queue and a human at every outward-facing boundary, turns out to be quite a lot.


Final piece in a series on the highest-friction problems in modern software engineering.

Next step

Tell us what is broken or what should exist.

Send the shape of the problem and any constraints you already know — budget, deadline, the stack you are stuck with. You will get a written reply from the engineer who would do the work, not a sales sequence.