Daemons & scheduled tasks
systemd=true in /etc/wsl.conf and, for unattended boot, loginctl enable-linger "$USER" (run once).
SENSITIVE_MODE=off is set in a daemon's service env. Emitting nothing is the expected default, not a fault. No secret ever goes into a unit file — credentials load from the gitignored .env in the engine clone.
The generic install driver
The five primary daemons install through one driver, which renders the matching template in scripts/templates/systemd/ (substituting workspace + interpreter) into ~/.config/systemd/user/ and enables it:
# <name> is one of: bridge | sentinel | fireside-bot | sync-exchange | eval-drift
bash scripts/install-daemon-service.sh <name>
bash scripts/uninstall-daemon-service.sh <name> # idempotent disable + remove
bash scripts/restart-daemon-service.sh <name> # restart + status tail
The interpreter resolves as: explicit PYTHON=... override → <workspace>/.venv/bin/python (the uv sync target, preferred) → first python3 on PATH. Generic status/logs for any unit:
systemctl --user status <unit>.service
journalctl --user -u <unit>.service -f
.service templates route stdout/stderr to journald, not a workspace log file — double-writing a log on a WSL2 /mnt/c 9P mount can freeze the process. Steady-state app logs still live in each daemon's own log file (e.g. .daemon-state/bridge.log).
1. Bridge daemon — living-interface dashboard
Purpose. A per-user FastAPI service bound to loopback 127.0.0.1, serving the browser dashboard (Pulse, Day, Inbox, Approvals, Tasks, Pipeline, Threads, Capabilities, Search, Settings, and more). It watches the workspace, bumps per-component version counters, and the browser polls via HTTP ETag heartbeats. Per the console-first rule, the dashboard is a convenience viewer only — every capability is also CLI/chat operable, and the daemon may be down without breaking anything.
Install
# Linux/WSL2 (canonical):
bash scripts/install-bridge-service.sh
# macOS:
python3 scripts/install-bridge-service-mac.py # writes a launchd plist (--uninstall to remove)
# Ad-hoc (no service):
python scripts/bridge-daemon.py --start
Manage / health
python scripts/bridge-daemon.py --health # live probe; exit 0 live / 1 fallback / 2 neither
python scripts/bridge-daemon.py --status
python scripts/bridge-daemon.py --rotate-token # restart the daemon after
python scripts/bridge-daemon.py --revert-config # roll back to the prior config snapshot
systemctl --user restart bridge-daemon.service
Config / state. Port is written to .daemon-state/port at boot; open http://127.0.0.1:<port>/. Runtime config: .daemon-state/config.yaml (last 3 snapshots kept); heartbeat at .daemon-state/heartbeat.json. Owned by the host you read the dashboard on (typically the laptop).
2. Sync-Exchange daemon — Exchange + calendar → bridge
Purpose. An in-process scheduler that pulls calendar events and emails from on-premises Microsoft Exchange (EWS via exchangelib) into readable markdown under outputs/_sync/, which the dashboard renders. Cadence: every 2 hours plus once at boot; emits a 1-minute liveness beat for the watchdog.
bash scripts/install-daemon-service.sh sync-exchange
python scripts/sync-exchange-daemon.py daemon # run forever
python scripts/sync-exchange-daemon.py status
python scripts/sync-exchange-daemon.py stop
# One-shot manual sync (no daemon):
python scripts/sync-exchange.py [--calendar|--emails] [--days N] [--email-count N]
Config / state. PID at .sync-exchange/daemon.pid, rotating log at .sync-exchange/daemon.log. Health pulse reader (consumed by /prime): scripts/sync-exchange-pulse.py. Exchange credentials load from the gitignored .env. In a two-host split this daemon is owned by the laptop (the host serving the dashboard); a VM copy should stay stopped, since data synced there would be stranded.
3. Sentinel — comms monitor
Purpose. A background comms monitor: checks Exchange email + Telegram on a fixed cadence (default every 15 minutes), scores urgency via the Claude API, and pushes alerts to a designated "urgent" Telegram channel.
bash scripts/install-daemon-service.sh sentinel
systemctl --user status sentinel.service
journalctl --user -u sentinel.service -f
# Chat surface: /sentinel
Config / state. Config from scripts/sentinel_config.yaml (ship scripts/sentinel_config.example.yaml as the template); log at .sentinel/sentinel.log. The alert channel and any chat IDs are instance config — keep them in .env / local YAML, never in the engine.
4. Fireside-bot — Telegram team bot
Purpose. A single in-process scheduler running team "fireside" jobs (poll/heartbeat, speaker DMs, weekly preview, day-of reminders, briefs, reports, health-check, topic collection).
bash scripts/install-daemon-service.sh fireside-bot
python scripts/fireside-bot-daemon.py daemon # run forever
python scripts/fireside-bot-daemon.py run <job> # one-shot a single job
python scripts/fireside-bot-daemon.py status
python scripts/fireside-bot-daemon.py stop
FIRESIDE_WEBHOOK_ENABLED=true, Telegram POSTs updates to a webhook endpoint and the poll_telegram cron job is skipped by design — so "last poll age" grows forever but is healthy. The real liveness signal is the heartbeat tick (every minute, both modes). Verify health via Telegram getWebhookInfo (low pending count, no recent error), not poll age.
Config / state. Schedule from config/fireside-schedule.json (ship scripts/fireside-schedule.example.json as the template). Runtime state under the data root is gitignored and daemon-owned — never re-track it, or a live host daemon's writes break git pull --ff-only. Webhook host/IP, bot token, and member names/usernames are all instance config.
5. Eval-drift — nightly regression detector
Purpose. A nightly trace-replay regression detector (daily at 02:00 local). For each skill with an evals/ folder, it replays the last 24h of traces against the current SKILL.md and flags pass-rate regressions. Sensitivity-aware: it auto-skips when sensitive mode is active, and needs SENSITIVE_MODE=off in its service env to emit anything.
bash scripts/install-daemon-service.sh eval-drift
python scripts/eval-drift-daemon.py daemon
python scripts/eval-drift-daemon.py --once # also: --dry-run, --skill <name>, status
6. Watchdog
Purpose. Classifies each daemon's liveness beat against its configured cadence + grace and routes a deduped tiered alert on a missed beat. It runs in-process inside the bridge daemon, config-gated by daemon.watchdog.enabled (default off). Importable core: scripts/watchdog_core.py.
python scripts/daemon-watchdog.py --once [--json] [--stale-default N]
Fleet health (observability, not a daemon)
python scripts/daemon-fleet-health.py [--stale N] [--json] [--exit-zero]
# /bridge-health [--stale N] [--gate] [--json] — chat wrapper
Reconciles the verdict + exit code to the worst status across hosts. Exit 0 healthy / 1 drift / 2 broken.
scripts/action-queue.py list|show|approve|edit|dismiss|retry|deposit operates in-process on a queue file under the data root — no bridge daemon, no loopback HTTP. approve is a synchronous, watched send in the same command (the human click is the send-gate; outbound send is always human-gated). The dashboard's queue page is read-only; the queue works with the daemon down. Chat surface: /queue.
Typical two-host topology
A common production split (present all hostnames/IPs as your own):
- Laptop host (where the dashboard is read): owns bridge + sync-exchange.
- Always-on Linux VM: owns fireside, sentinel, eval-drift, plus a watchdog. It runs from fresh engine + data clones (siblings, so the data root resolves). systemd repoints use drop-in overrides under
/etc/systemd/system/<unit>.d/override.conf.
Scheduled tasks
Two scheduling systems plus healthcheck pings.
A. Claude Code durable scheduled tasks
Machine-local cron-style tasks persisted in .claude/scheduled_tasks.json (managed by the Claude Code runtime — do not hand-edit). They do not sync; each machine keeps its own set.
cat .claude/scheduled_tasks.json | python -m json.tool # inspect (read-only)
# Tools: CronList (list) / CronCreate (add) / CronDelete <task-id> (cancel)
There is no automatic cleanup; prune orphans with CronList + CronDelete. The file is operator-specific and should be absent/gitignored in a public clone — document only the mechanism, never the payloads.
B. Sentinel 15-minute loop
Not a cron entry — the Sentinel daemon's own internal loop (check_interval_minutes, default 15). Managed by installing/running the Sentinel daemon above.
C. systemd-user timers (Linux/WSL2 housekeeping)
Each is a oneshot service fired by a .timer; standalone installers render the templates in scripts/templates/systemd/.
| Timer | Cadence | Install | Runs |
|---|---|---|---|
ollama-autoupdate.timer | daily, persistent | system-wide installer (sudo) | Upgrades Ollama only when a newer tag exists; smoke-tests bge-m3 |
memory-index-refresh.timer | daily 03:30 | bash scripts/install-memory-index-timer.sh | memory-index.py build (incremental re-embed) |
memory-hygiene.timer | Mon 07:34 | bash scripts/install-memory-hygiene-timer.sh | memory-hygiene.py (defect detector) |
odin-cadence.timer | Mon 09:00 (in HEADING_OS_TZ) | bash scripts/install-odin-cadence-timer.sh | odin-cadence-notify.py (nudge) |
ops-radar.timer | daily 08:00 (in HEADING_OS_TZ) | bash scripts/install-ops-radar-timer.sh | ops-radar-notify.py (manual-action detector) |
systemctl --user list-timers <name>.timer --no-pager
systemctl --user start <name>.service # run once now
systemctl --user disable --now <name>.timer # cancel
The odin-cadence/ops-radar templates substitute HEADING_OS_TZ (default UTC) so no operating locale is baked into the engine. The ollama-autoupdate timer is system-wide (sudo); each real upgrade restarts the ollama service → a few-seconds blip for recall.
D. Healthcheck pings (deadman monitoring)
Not a scheduler — best-effort deadman pings via scripts/utils/healthchecks.py. Idempotent setup writes ping URLs back as gitignored .env keys:
python scripts/setup-fireside-healthchecks.py # fireside checks (needs HEALTHCHECKS_API_KEY)
python scripts/setup-daemon-healthchecks.py # sentinel / eval-drift deadman checks
Daemons ping at runtime (fireside every minute, sentinel each ~15-min cycle, eval-drift after the 02:00 run). The API key and all ping URLs are gitignored .env values — never commit them.
31C-Sync scheduled task / launchd agent / systemd timer. The model is plain git: code down = git pull --ff-only; data up / backup = python scripts/push-all.py; /sync = pull then backup. The only recurring schedule that remains by default is the 15-minute Sentinel loop (plus any housekeeping timers you install).
Summary
| # | Daemon | Cadence | Install | Health |
|---|---|---|---|---|
| 1 | bridge | persistent (FastAPI) | install-bridge-service.sh / -mac.py | bridge-daemon.py --health |
| 2 | sync-exchange | every 2h + boot | install-daemon-service.sh sync-exchange | sync-exchange-pulse.py |
| 3 | sentinel | every 15 min | install-daemon-service.sh sentinel | deadman ping |
| 4 | fireside-bot | webhook + 1-min heartbeat | install-daemon-service.sh fireside-bot | getWebhookInfo / fireside-pulse.py |
| 5 | eval-drift | daily 02:00 | install-daemon-service.sh eval-drift | deadman ping |
| — | watchdog | in-process (bridge) | config flag | daemon-watchdog.py --once |