Daemons & scheduled tasks

Every long-running daemon and every scheduled task, with exact install / inspect / cancel commands. All real data, credentials, hostnames, and chat IDs live outside the engine in the gitignored .env / .sessions and the data overlay — replace the placeholders with your own values.

Platform model Linux/WSL2 uses systemd user units; macOS uses launchd agents. There are no Windows-native installers — on Windows the daemons run inside WSL2 as the Linux units. WSL2 needs systemd=true in /etc/wsl.conf and, for unattended boot, loginctl enable-linger "$USER" (run once).
Telemetry is opt-in and fail-closed Langfuse tracing is suppressed everywhere unless 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
Why logs go to journald All .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
Webhook mode — "last poll age" is a red herring With 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]
Caution Do not run the watchdog CLI before the daemons have been restarted with the beat code live, or it will falsely classify all daemons as missing and fire real alerts.

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.

The Action Queue is daemon-free Worth stating explicitly: the Action Queue needs no daemon. 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):

Capacity Size an always-on VM for lightweight services only. If swap is 0, an inference overshoot becomes an OOM-kill that starves the live daemon heartbeats — do not run LLM inference on the daemon host.

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/.

TimerCadenceInstallRuns
ollama-autoupdate.timerdaily, persistentsystem-wide installer (sudo)Upgrades Ollama only when a newer tag exists; smoke-tests bge-m3
memory-index-refresh.timerdaily 03:30bash scripts/install-memory-index-timer.shmemory-index.py build (incremental re-embed)
memory-hygiene.timerMon 07:34bash scripts/install-memory-hygiene-timer.shmemory-hygiene.py (defect detector)
odin-cadence.timerMon 09:00 (in HEADING_OS_TZ)bash scripts/install-odin-cadence-timer.shodin-cadence-notify.py (nudge)
ops-radar.timerdaily 08:00 (in HEADING_OS_TZ)bash scripts/install-ops-radar-timer.shops-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.

Retired — do not install an hourly workspace-sync The old copy-and-orphan-delete sync engine is retired. There is no 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

#DaemonCadenceInstallHealth
1bridgepersistent (FastAPI)install-bridge-service.sh / -mac.pybridge-daemon.py --health
2sync-exchangeevery 2h + bootinstall-daemon-service.sh sync-exchangesync-exchange-pulse.py
3sentinelevery 15 mininstall-daemon-service.sh sentineldeadman ping
4fireside-botwebhook + 1-min heartbeatinstall-daemon-service.sh fireside-botgetWebhookInfo / fireside-pulse.py
5eval-driftdaily 02:00install-daemon-service.sh eval-driftdeadman ping
watchdogin-process (bridge)config flagdaemon-watchdog.py --once