Supervisor (opsd)

opsd runs once per machine. It starts agent runs, watches them, renews their claims, ends them when they run over budget, runs schedules, wakes agents when work arrives, syncs the data roots, and serves a local API for the desktop app. It replaces v1's launch.sh, waitpids.sh and watch.sh.

Start and stop

ops daemon start            # in the background, no window; log in <state>/opsd.log
ops daemon status           # pid, port, version; exit 3 when not running
ops daemon stop             # runs keep going; the next opsd adopts them
ops daemon stop --kill-runs # also stop every run
ops daemon restart          # stop and start; live runs keep going and are adopted
ops daemon pause            # no new runs or schedules until resumed; live runs finish
ops daemon resume
ops daemon run              # in this terminal, until Ctrl+C

On Windows, Node puts each child it starts into a job object that ends the child when opsd exits, and starting runs detached would leave them without a console, so their own console tools would open visible windows. opsd starts each run there through a hidden cmd.exe that runs a small launcher (node -e, with a fixed command line). The job lets that shell's children break away, so the launcher runs outside the job and keeps the hidden console when the job ends the shell.

  1. The launcher sends its PID to opsd over a named pipe with a random name.
  2. opsd reads the launcher's creation time while it waits, and saves its PID, image and creation time in the run record (shim_pid keeps the shell's).
  3. Only then does opsd send the command, its arguments, its environment and the prompt. The launcher starts the command as its own child, in a kill-on-close job, and forwards the prompt to its stdin.

No argument passes through a cmd.exe command line, except for a .cmd or .bat runtime, which cmd.exe must read: there, an argument with ", %, !, CR or LF is refused at launch. If opsd exits, aborts the launch or fails to record the launcher, the pipe closes and the launcher exits without starting anything. Killing the launcher ends the command too. A stop gives a run still launching up to 5 s to finish, then ends it here and releases its claim, so every run handed to the next opsd names a launcher it can adopt by PID and creation time.

GET /api/health reports runs_outlive. Where it is false, opsd refuses a stop or restart without --kill-runs while runs are live (exit 1, nothing changes), and Ctrl+C on ops daemon run ends the runs and releases their claims.

A stop waits up to 15 s for the old process to exit. If it is still alive, the stop fails and a restart starts no second opsd.

A start waits up to 75 s for the new opsd to answer while its process runs, and stops waiting as soon as that process exits. Before it answers, opsd reads process creation times (its own for the lock, a stale lock holder's, and those of the runs it adopts); on Windows each read starts PowerShell, which a loaded machine can hold up to its 15 s timeout.

A pause is opsd.paused: true in the user config, so it holds across restarts, and a stopped opsd starts paused. While paused, opsd refuses every new run (wakes, schedules, ops launch, Run now) with the reason in ops waits; helper runs of live runs still start. Sync, notifications, presence and the API keep working.

The opsd command (packages/supervisor/dist/main.js) takes the same verbs. Start opsd from a folder inside a data root, or list roots in the user config, so it finds the tasks.

State lives in ~/.agent-ops/opsd/ (or OPS_STATE_DIR) and is never committed:

Path Content
opsd.json pid, port and API token of the running opsd (mode 0600 on Linux and macOS)
opsd.lock PID and creation time of the running opsd; one opsd per state folder, even when two start at once. A lock is replaced only when its holder is certainly gone; if opsd cannot tell, it refuses to start and names the file to delete.
opsd.lock.takeover held for a moment while a starter replaces a stale lock; never reclaimed automatically. If a start crashed while holding it, opsd refuses and names it.
opsd.log the supervisor's own log
runs/<id>.json one record per run: agent, task, pid, budget, tokens, outcome
logs/<id>.log the run's output (Claude stream-json, Codex --json)
schedules.json when each schedule last ran
wake.json which inbox items each waking agent was last started for

Runs

ops launch codex:engineer --task AO-0009 --budget 2h --tokens 400k
ops runs                    # live runs; --all for the last 30
ops logs 7Q2K               # end of a run's log; a run id or its last characters
ops kill 7Q2K               # stop the run and its process tree
ops procs 7Q2K              # every process the run started; --kill stops what an ended run left
ops usage                   # each provider's usage allowance, and whether it pauses new runs
ops waits                   # what waking agents could start, and why each waits (needs opsd)
ops spawn -m "look up X"    # inside a run: start a helper under the run's claim
ops procs 7Q2K --kill --pid 4120   # stop one of them, such as an unsure one you checked

A run with a task:

  1. opsd checks the launch, then claims the task for the agent before the process starts, so two runs never race for it. If starting the process fails, opsd releases the claim it took.
  2. It starts the runtime with the prompt on stdin, OPS_AGENT, OPS_RUN, OPS_ROOTS, OPS_RUN_TOKEN and OPS_RUN_STATE_DIR set, and writes a run-start event on the task. See Run tokens.
  3. While the process lives, it renews the claim when less than half of the lease is left (a renew event), counts tokens from the log, and kills the run when it passes its time or token budget.
  4. When the process ends, it writes a run-end event (exit code, reason, minutes, tokens). If the agent still holds the task, opsd releases it, so the task goes back to the queue at once instead of waiting for the lease to run out.

The budget comes from --budget, then the task's delegation (ops delegate --budget), then the agent's budget in the opsd config, then the role file's budget_min, then budget_minutes (default 180). The token budget comes from --tokens, then the delegation. Tokens counted are input, cache writes and output; cache reads are left out.

Runtimes

Runtime Command line
claude claude -p --permission-mode acceptEdits --output-format stream-json --verbose [--model M] --allowedTools ... --disallowedTools ... --add-dir ...
codex codex exec [-m M] --json --sandbox workspace-write --skip-git-repo-check -C <cwd> --add-dir ... -
other the config's command and args, prompt on stdin

Processes

Every process starts with no console window. opsd stops only processes it started, and never with a tree kill by bare PID: Windows matches a tree by parent PID alone, which can reach a process whose parent PID was reused.

opsd records each run's process creation time before it saves the run. A run adopted from an earlier opsd is watched only while its PID still has that creation time, so a later process that reuses the PID is never taken for the run. A run with no creation time on record is marked lost and never stopped. When the creation time of a live PID cannot be read at adoption (on Windows, PowerShell can miss its timeout on a loaded machine), opsd keeps the run adopted, warns, and reads it again in the background, up to five times 10 s apart; the run is marked lost as soon as a read shows another process. Stopping opsd cancels a pending read and the wait between reads, so they never hold opsd past the wait of opsd stop; the next opsd checks the run again. A kill still checks the creation time at the moment of the kill.

The desktop app can host opsd: it starts it with its own executable in Node mode (ELECTRON_RUN_AS_NODE), and agent runs do not inherit that flag.

Process ledger

Each run keeps a ledger of every process it started, in procs/<run>.json in the state folder: PID, parent PID, name, command line, creation time, and when opsd first and last saw it. A process is named by its PID and its exact creation time together.

Platform Snapshots Ledger
Windows one PowerShell helper answers every snapshot and kill (Get-CimInstance Win32_Process) yes; creation times to the millisecond
Linux read from /proc yes; creation times in clock ticks
macOS ps no: ps reports whole seconds, too coarse to tell a reused PID apart; runs are stopped by process group only

What can still escape or remains:

Windows job objects would close the first gap on Windows; add them if it shows up in practice.

Limits

opsd starts a run only when every limit has room. A refused ops launch names the limit, and wake and schedules wait for room.

Limit Setting Counts Default
PC max_runs, min_free_gb live runs on this PC; free memory number of CPU cores; 4 GB
Provider runtimes.<name>.max_live live runs of that provider on this PC none
Usage allowance usage_pause_at, runtimes.<name>.pause_at the provider's plan windows, as its runs report them pause at 90% of a window until it resets
Role per project roles.<role>.per_project live runs of the role on one project, over every provider 1 for roles that write; 3 for qa, reviewer, orchestrator
Role total roles.<role>.max_live live runs of the role on every project none; 1 for orchestrator
Checkout none live runs of roles that write in one folder 1

Config

The opsd section of the user config (%APPDATA%\agent-ops\config.yml, ~/.config/agent-ops/config.yml or OPS_CONFIG):

me: human:dawid
opsd:
  port: 4747 # 0 picks a free port
  tick_seconds: 15
  proc_seconds: 5 # how often the processes of live runs are looked at
  max_runs: 24 # default: the number of CPU cores
  min_free_gb: 4 # no new run below this much free memory
  usage_pause_at: 0.9 # no new runs on a provider past 90% of a usage window
  usage_cooldown_minutes: 30 # pause after a usage-limit error that names no reset
  signed_out_handoff_minutes: 15 # hand named work of a signed-out provider to another; 0: never
  budget_minutes: 180
  wake_cooldown_minutes: 10
  sync: true # commit, pull and push each root when its sync policy says so
  seeded: 1 # the defaults were written once; from then on runtimes lists every provider
  runtimes:
    claude: { command: claude, model: opus }
    codex: { command: codex, pause_at: 0.95 }
    hermes: { command: hermes, args: [run, --json, '-'], unset_env: [GH_TOKEN], paused: true } # paused: no new runs
    # sign_in_check: the status command's arguments after `command` and `args`; false: none, try a run hourly
    other: { command: other, sign_in_check: [whoami] }
  # Without `seeded`, entries merge into the default claude and codex key by key, as before.
  review_backlog: 3 # reviews waiting in a project that hold new work there
  roles:
    engineer: { per_project: 1, providers: [claude, codex], overflow: true, max_children: 2 }
    qa: { per_project: 3, burst: 6 } # qa and reviewer only read
  agents:
    codex:engineer: { wake: true, budget: 2h, model: gpt-5-codex }
    claude:qa: { wake: true, cwd: 'C:\dev\agent-ops' }
    claude:pm: {} # an agent with no settings of its own
  schedules:
    - name: digest
      at: '09:00' # daily, local time; or every: 30m
      agent: claude:pm
      prompt: Write the daily digest of open tasks and post it to the ops channel.
      paused: false # true keeps it without starting runs
  repeats: true # create the tasks of due repeats; false on every PC but one that shares a store

Chat

The desktop Chat view is one conversation with the PM (opsd.chat.agent, default claude:pm, which every new config seeds).

The conversation lives in chat.json in the state folder, on this machine only.

opsd:
  chat:
    agent: claude:pm # one Claude agent, no wildcard
    mentions: true # channel posts that mention it join the chat
    budget: 20m # one turn; default: the agent's budget

Merging approved work

With opsd.merge.<root name> set, opsd merges a task's work into a base branch once a review approves it. The setting lives in the user config, not in the task store's config.yml, so the commands opsd runs are set on each machine and never arrive through a sync.

opsd:
  merge:
    v2: # the root's name
      base: v2 # the branch approved work goes into
      checkout: 'C:\dev\agent-ops' # where the base is built; default: the root's git top
      checks: # run on the rebased work, in opsd's own worktree
        - npm install --no-audit --no-fund
        - npm run typecheck
        - npm run lint
        - npm test
        - npm run leaks
      build: [npm run build] # run in `checkout` after the base moved
      timeout_minutes: 30 # per command

Pushing task branches

Work that is not merged yet lives on task branches, and without a push only on this machine. At each sync that pushes a root (the scheduled push under the root's sync policy, and the sync after every run), opsd pushes the task branches of the root's repo to its remote. It is on for every root; to turn it off for one, set it to false in the user config:

opsd:
  push_branches:
    v2: false # the root's name

Machines

With machine set in the user config, opsd writes machines/<machine>.json in each data root it syncs (not in mirror roots): last seen, live runs and their agents, opsd version and build. It rewrites the file only when something changed or the record is 20 minutes old, so syncs carry it without a commit every tick. A machine counts as alive when it wrote in the last 70 minutes. GET /api/machines lists every machine seen; Agents shows them.

Notifications

Set opsd.notify.webhook (a Discord or Slack incoming webhook, https; http only to this machine) in Settings or the user config, never in a data root. Each tick, opsd posts new inbox items for me (asks, reviews, findings to triage, decisions, mentions, messages) and runs that failed (non-zero exit, lost, over budget) as one message, at most 6 messages per 10 minutes; items already waiting when notifications start are not sent. opsd.notify.kinds limits what is sent. The URL is a secret: settings never return it, and warnings say only the HTTP status.

API

opsd listens on 127.0.0.1 only. Every request needs the token from opsd.json as Authorization: Bearer <token> (only GET /api/events also takes ?token=, because EventSource cannot send headers) and a 127.0.0.1 or localhost Host header, so a web page in a browser cannot call it.

Run tokens

The token in opsd.json is the full token: the desktop app and ops outside a run use it, and it acts as whoever as names. Each supervised run also gets its own random token in OPS_RUN_TOKEN; opsd keeps only its SHA-256 in the run record, so an opsd that adopts the run still knows it. Inside a run, ops sends that token only to the opsd on OPS_RUN_STATE_DIR; an opsd on another state folder, such as one started by a test, gets the full token from its own opsd.json.

A run token:

These checks prevent accidental or ordinary API use from crossing run boundaries. A run still runs as your operating-system user, so it can read files that user can read. To request a daemon restart or another human-only action, use ops ask.

Method and path Effect
GET /api/health pid, version, build (when the code opsd runs was built), live run count, me
GET /api/doctor the ops doctor checks, run by opsd on the env and config it runs with
POST /api/roots/:name/mirror turn mirroring into the Master Repo on or off for a project root: on (true or false)
GET /api/events Server-Sent Events: hello, run, tasks, changed (tasks, channels or votes), sync, warn, chat (run and streaming, the live reply so far; or changed)
GET /api/chat, POST /api/chat the chat: messages, the live turn, streaming, busy (another run of the agent), waiting (why queued messages wait); POST sends text and starts a turn when the limits have room
POST /api/chat/stop|reset stop the live turn (or the run queued messages wait for); start a new conversation
GET /api/runs, GET /api/runs/:id run records (?live for live ones)
GET /api/runs/:id/log?tail=N the end of a run's log, as text
GET /api/usage each provider's usage windows, live runs and pause reason
GET /api/waits providers (as /api/usage) and waits: tasks waking agents could start, in wake order, with the reason
POST /api/runs launch: agent, task, prompt, cwd, budget, tokens; parent for a helper of a live run
POST /api/runs/:id/kill stop a run
GET /api/runs/:id/procs, POST /api/runs/:id/procs the run's process ledger with alive for each; POST kills what an ended run left, or pid alone
GET /api/tasks, GET /api/tasks/:id tasks (?all includes closed and archived), one task with its events
POST /api/tasks create a task: title, body, to, type, priority, project
POST /api/tasks/:id/:action comment, answer, start, block, review, unblock, done, release, reopen, park, assign, priority, retype (type; optional to, priority and answer land in the same write), verdict, delegate
GET /api/agents, GET /api/inbox agents (role files and the opsd config) with wake and a live run, the actor's inbox
GET /api/decisions, POST /api/decisions/:id/vote|decide votes
GET /api/channels, GET|POST /api/channels/:name channels with descriptions; read and post
POST /api/channels create a channel: name, description
POST /api/notify/test send a sample message to the notification webhook; the answer never holds the URL
GET /api/machines every machine seen in the roots, newest record each, with alive
GET /api/report?days=N progress per project and usage per provider and agent over the last N days (1 to 366), decisions and channel posts
GET /api/activity?q=Q&before=C&limit=N one page of task events (plus task creation), archived tasks included, and channel posts, newest first: items, next (pass as before), total, actors, projects; limit 1 to 500, default 100
GET /api/timeline?project=P&root=R&days=N&clip=S&gap=M the replay timeline, archived tasks included: lanes per actor, work spans (claim to release), review rounds as reviews, waits (ask to answer), runs with tokens, decisions, learn notes, created and closes, totals for stat cards, and time, a map from real to playback time where idle stretches play as gap minutes (default 30) and the whole as clip seconds; window as for the report (days, or from and to)
GET /api/kb?q=WORDS&topic=T learn notes across roots; every word must match (as ops kb); no words lists them all
GET /api/kb/:topic/:slug?root=R one learn note; root when the ref exists in several roots
GET /api/standup?hours=N per agent: done, sent for review, working on, blocked; runs, tokens, minutes; #standup posts
GET /api/schedules, POST /api/schedules schedules with last_run and next_run; add one, or change the one named previous
POST /api/schedules/:name/run|delete start a schedule's run now (under the limits), or remove it
GET /api/repeats, GET /api/repeats/:name repeating tasks with state, rule, next, fired and last
POST /api/repeats add (root, name, title, start, repeat, days, when, until, count, owner, project, priority, type, labels, parent, body, paused); replace: true replaces one; human only
POST /api/repeats/:name/pause|resume|run|delete pause or resume it, create its task now, or delete it (its tasks stay); :name may be root%2Fname; human only
GET /api/settings, POST /api/settings effective settings and the keys the file sets; patch changes safe keys only (null restores a default)
POST /api/shutdown stop opsd (killRuns: true to stop runs too; ifIdle: true stops only with no run live, else 409)

Writes act as as in the body or query, or as the user config's me; opsd ignores its own OPS_AGENT. Errors return { "error": "..." } with 400 (bad input), 401, 403, 404 or 409 (refused).