The daemon — Sigil with the terminal closed
Sigil's scheduler needs a process that outlives a command. The daemon is that process: start it once and your schedule runs whether or not the TUI is open.
sigil daemon start # background, ticks every 30s
sigil daemon start 5 # tick every 5 seconds instead
sigil daemon status
sigil daemon logs 50
sigil daemon stop
sigil daemon install # start at login (launchd on macOS, systemd on Linux)
sigil daemon uninstall
sigil daemon run [interval] is the supervisor loop in the foreground — what
start spawns, and what the service unit executes. Run it directly when you want
to watch it work.
Status
$ sigil daemon status
sigil daemon: running — pid 86668, up 14s, tick 5s
graph : /Users/you/.sigil/app/.jac/data/sigil.db
log : /Users/you/.sigil/log/daemon.log
jobs : 3 scheduled, 3 armed
next due: 2026-08-07 09:00:00 (in 17h22m)
at login: installed (sigil daemon install)
Each log line is a heartbeat:
2026-08-06 11:53:33 sigil daemon up — pid 86668, tick 5s, graph …/sigil.db
2026-08-06 11:53:36 tick: fired 0 job(s), 3 armed
2026-08-06 11:54:15 tick: fired 1 job(s), 3 armed
2026-08-06 11:54:15 morning_digest -> ok
fired 0, 0 armed and fired 0, 3 armed are different diagnoses — the first says
the scheduler cannot see your jobs, the second says nothing is due yet.
One agent, wherever you run it
Sigil's whole state — soul, memory, skills, schedule — is one graph, and jac
resolves that graph's store relative to the working directory. Run sigil
from two directories and you had two agents that had never met.
Every entry point now pins its working directory to the project, so the CLI, the
TUI, the daemon, and every job the daemon fires are the same agent. sigil where
answers the question directly:
$ sigil where
graph : /Users/you/.sigil/app/.jac/data/sigil.db
project : /Users/you/.sigil/app
invoked in: /Users/you/code/some-project
daemon : running
Paths you type still resolve against the directory you typed them in — sigil
compile ./SKILL.md reads the SKILL.md next to you, not next to the project.
(That is also a fix: the installed launcher already changed directory, so relative
paths could not be found at all.)
What the daemon runs
Every tick runs in a fresh subprocess. That is deliberate: a long-lived jac process serves the graph it loaded at startup, so a supervisor doing the work in-process would never see a job added after it booted — the daemon would be up and nothing would fire. The loop supervises; the subprocesses do the work and see the current graph.
One job runs at a time. A long solve delays the next tick rather than stacking runs on top of it.
Approvals in the background
The exec gate (sigil approvals) still applies to a job the daemon fires, and
nothing headless can answer a prompt. A command that needs approval is blocked
and reported — the job continues and says what was refused; it does not hang
and it does not silently run.
Blocked commands queue up for you:
sigil approvals pending # what is waiting, and for how long
sigil approvals approve "rsync" allow-always # answer one
sigil approvals allow "rsync *" # or allowlist it ahead of time
sigil approvals clear # drop the queue
The queue deduplicates by command, so a job that retries the same blocked command every hour asks once rather than filling the graph with copies of one question.
To let background work run unattended, allowlist what it needs
(sigil approvals allow) rather than loosening the policy globally.
Service files
sigil daemon install writes and loads:
| OS | Unit |
|---|---|
| macOS | ~/Library/LaunchAgents/com.sigilagent.sigil.plist (RunAtLoad, KeepAlive) |
| Linux | ~/.config/systemd/user/sigil.service (Restart=always) |
Both pin the working directory to the project and append to
$SIGIL_HOME/log/daemon.log. sigil daemon uninstall unloads and removes them.
State lives under $SIGIL_HOME (default ~/.sigil): run/daemon.pid,
run/daemon.json, log/daemon.log.
Stopping
sigil daemon stop sends SIGTERM and waits up to 30 seconds. A tick that is
mid-solve can legitimately outlast that; the command says so and hands you the
pid rather than escalating to SIGKILL behind your back.
The Observatory
sigil serve runs the web UI and the HTTP API against the same graph as
everything else — the CLI, the TUI, the daemon, and every job it fires.
That had never been true. Measured three ways: the CLI reported 9 skills while
POST /walker/api_soul on the running server reported skills: 0; a teach
through the server answered "remembered" and the fact landed in no anchor in the
store, so anything done in the web UI was thrown away; and sigil serve exec'd
jac start directly, skipping the guest-root alignment src/main.jac performs.
Root alignment alone does not fix it — a server booted with __guest__ already
pointing at the superroot still reads an empty Soul. So the endpoints stop owning
a graph. Each one runs its request in a fresh jac run process, the execution
mode that demonstrably reads and writes the real store, and returns its result.
The same seam the scheduler and the channel bridges use.
sigil serve # Observatory + API, on the real graph
sigil api soul '{}' # the same seam, from the shell (JSON out)
A request therefore costs one process start. For a local dashboard that is the right trade against showing a confident, empty agent.
Running it in the background
Bare sigil serve holds the terminal until Ctrl-C. The subcommands give it the
same lifecycle the daemon has:
sigil serve start # detached; the prompt comes straight back
sigil serve status # running? which pid, which port, which graph
sigil serve logs [n] # what the foreground run would have printed
sigil serve stop
start returns as soon as the server is spawned and confirmed alive — it does
not wait for the web client to finish building, which takes about half a minute
on every boot. So the first few seconds after it returns, the URL is not up yet;
status and logs say where it has got to. Nothing is streamed to the terminal
either way.
The port is claimed strictly. start refuses if anything already holds it rather
than sliding to the next one, because a stale listener on 8199 is exactly how you
end up watching a previous run's agent without knowing. For the same reason
stop will only signal a process it can identify as an Observatory — a foreign
server on the port is reported and left alone.
An Observatory started any other way (a bare jac start, or a sigil serve in
another terminal) is still visible to status, and stop will adopt and stop it:
the foreground path registers itself, and an unregistered one is recognised from
the port.
Lifecycle hooks
sigil hook add <name> <event> <action-kind> <action> — four documented events
had no call site anywhere in the codebase, so registering for them registered for
something that never happened. All of them fire now:
| Event | When |
|---|---|
gateway_start |
the daemon comes up |
session_start |
a transcript begins — a new peer, or the daily reset |
before_tool |
ahead of a tool call (the exec gate included) |
after_tool / tool_failed |
after one, split by outcome |
alongside the four that already worked: before_solve, after_solve,
solve_failed, message_received, cron_fired. A hook that raises is contained
— it cannot take down the solve, tool call or reply it was observing.
Shell jobs outlive the turn that started them
ws_exec never kills on a timer, but its job registry used to be in-memory: a
build started in one turn was invisible from the next CLI invocation, from the
daemon, and from a restarted TUI — still running, still spooling, with no way to
watch or stop it. Jobs are recorded under $SIGIL_HOME/run/jobs/ now, so
ws_jobs lists them, ws_watch reads their output, and ws_kill stops them
across process boundaries. Records for finished jobs are pruned as they are seen.