Architecture

One Python package, three independent lanes, two persistence surfaces (EventKit + JSON state files). Everything funnels through daemon.sync_once on a 5-second tick.

Module map

The daemon orchestrates all three lanes per cycle. api.py / events.py are plumbing — wired but not called from the cycle yet.

Ownership boundaries

SurfaceOwnerNotes
Beads: <project> bodiesDaemon (most) + user (notes)<bb:meta> + <bb:desc> are daemon-owned. <bb:notes> is user-owned and preserved across syncs.
Beads: Activity · ! Beads: ReadmeDaemonDrift is overwritten next cycle.
Beads: Projects · Beads: Settings rowsDaemon writes / user togglesBody+title overwritten on drift; completion is the only signal the daemon reads back.
Voice: <slug> header + briefDaemonUser edits are clobbered. User responses go in new reminders.
Voice: <slug> responsesUser (or voice agent on behalf of user)Drained via rbridge mailbox read.
Claude: Sessions · Codex: SessionsUser (request) / daemon (lifecycle)User writes the prompt; daemon marks completed / appends output / appends agent turns.

State files

JSON state lives under ~/.claude/. None of these are checked into git.

State files solve one problem: EventKit reminder IDs are not stable across iCloud sync churn, so the daemon needs a side-table linking bead IDs to reminder IDs. The same pattern applies to in-flight captures, chat sessions, and voice mailboxes — each is a short-lived contract between "this process I spawned" and "the reminder that triggered it".

Why three lanes and not one big sync

The three lanes (Beads / Sessions / Voice) only share the EventKit adapter and the activity log. Their data models, lifecycles, and failure modes are all different — coupling them would force the simpler lanes to inherit the complexity of the most complex one.

The daemon's job is to call each lane in sequence per cycle and isolate failures: a panic in sessions.poll never breaks reconcile_project.

What's not in the daemon

For the per-cycle flow inside sync_once, see Sync cycle.