A coding-agent session that survives longer than a terminal window has to answer one question at every moment: which machine may write to the provider right now. The obvious design is a lock. The working design is custody, and the difference matters as soon as the laptop closes.
A lock says “wait until I release this.” Custody says “one machine is the execution custodian; everyone else can still read, and can still submit work, but their submissions become durable commands the custodian settles.” When the custodian’s lid closes, nothing blocks. The lease expires, a new custodian can acquire it, and the old machine’s delayed writes are rejected by construction rather than by timing luck.
This is the model Oompa (@hraness/oompa, a Bun CLI and daemon that runs managed Codex, Claude Code, and Devin sessions) uses for its synced sessions. The pieces are small enough to read in one sitting: a lease record, a fencing token, and a heartbeat protocol, all in src/cloud/leases.ts.
the custody model
Oompa’s rule is stated plainly in its repository guidelines: one machine is the fenced execution custodian for a session. Other devices may read the session’s projections and submit durable commands; they may not create a second provider writer.
The split is deliberate. Reading a transcript, checking status, or queuing a “keep going” message from a phone are all safe things to do from anywhere. The dangerous operation is narrower: opening a provider turn, answering a tool-approval callback, steering mid-turn, switching providers. Those effects go through exactly one writer, and the lease is how the system knows which machine that is.
This division is what lets the rest of the system stay simple. The sync projection is bounded and can be shown on any device. The command record is durable and idempotent, so a submission from a second device is data, not a competing process. Only the effect-bearing path needs a single owner, so only that path is fenced.
In practice the non-custodian devices are more capable than “read-only” suggests: from a phone you can watch the projected turn, queue a message, steer the active turn, or decline a permission prompt. Each of those arrives at the custodian as a durable command to settle under the current lease, which is why they are safe to offer everywhere. What no other device can do is open a provider writer of its own.
the lease record
The lease itself is a snapshot with seven fields:
export type LeaseSnapshot = Readonly<{
bootGeneration: number;
bootId: string;
devicePublicId: string;
fence: number;
heartbeatFingerprint: string;
heartbeatSequence: number;
leaseUntil: number;
}>;
Three of those fields form the authority tuple that gets stamped on every command the custodian executes: bootGeneration, bootId, and fence. bootId identifies one running daemon instance; bootGeneration is a monotonic counter that moves forward when a daemon should be treated as a new authority even if its identity looks similar; fence is the number that invalidates stale holders.
Two fields exist only for liveness bookkeeping: heartbeatSequence and heartbeatFingerprint make heartbeats themselves idempotent and ordered, so a duplicated or delayed heartbeat cannot silently extend a lease. leaseUntil is the deadline, and devicePublicId binds the lease to one enrolled device.
Lease durations are validated, not trusted: validLeaseDuration accepts only a safe integer between 5,000 and 120,000 milliseconds. Anything else is a closed invalid_duration rejection. Bounds like this are a habit the whole codebase keeps; an unbounded input is treated as a contract violation, not an edge case.
fencing, not locking
The interesting part is what happens when the same daemon comes back after its lease expired. acquireLeaseDisposition returns renewed only while the existing lease is still live and the boot identity matches. Once now >= leaseUntil, reacquisition returns acquired with fence: existing.fence + 1, even when the same bootId and bootGeneration are asking. The code comment states the reason directly: after expiry, even the same daemon identity receives a new fence so any delayed work from the previous lease remains fenced out.
That one rule is the whole argument for fencing over locking:
- A lock held by a dead process either blocks forever or needs a timeout plus a hope that the old holder stays dead. A fence needs only the deadline. When the deadline passes, a successor takes over, and the fence counter guarantees the old holder cannot apply a late write.
- Every mutation carries the authority tuple it was admitted under. A write that arrives with
(bootGeneration, bootId, fence)that no longer matches the live lease is rejected aslive_authorityorbound_authority, depending on which check fails first incommandAuthorityTransitionDisposition. The rejection returns the unchanged projection, so model-level tests can assert that no state moved. - Heartbeats have their own replay discipline. A repeated
(sequence, fingerprint)pair is areplay. A repeated sequence with a different fingerprint is afingerprint_conflict. A skipped sequence is asequence_gap, an old one isstale_sequence, and a heartbeat presented after expiry or under the wrong authority is rejected asauthority. Nothing about liveness is inferred from “the process seems alive.”
The result is that the closed laptop is a normal event, not an exceptional one. The lease runs out. A daemon elsewhere (or the same daemon after it wakes and re-acquires) gets fence n+1, and the work the old custodian might still think it owns is already invalid.
The heartbeats that keep a live lease alive are equally bounded. The daemon’s sync cadence adapts: one second while another device is present or a local session is mid-turn, fifteen seconds otherwise, with a device-presence probe cached for ten seconds so the fast cadence does not list devices every second. Liveness is expensive enough to cache and cheap enough to lose, which is the right ratio for a value that only matters while it is fresh.
process custody under the lease
The lease answers which machine may write. A second custody layer answers which process the session is actually bound to, because provider threads outlive daemons.
For Claude Code, a managed session is a real child process, and resuming it launches claude --resume with the exact session ID. Oompa will only launch that resume after proving the prior process is not live, using a PID-domain, PID, and process-start-token tuple. The token comes from ps lstart, which has one-second granularity; the documented failure mode is a rare PID-reuse alias, and the resolution is conservative. An ambiguous liveness answer retains custody rather than authorizing a handoff to a possibly-live conversation. If a daemon generation changes while a Claude side is still held, oompa session recover returns RECOVERY_REQUIRED and only an explicit oompa session abandon settles local authority, with provider-state-unknown evidence preserved.
For Codex, thread lifetime belongs to the pinned app-server rather than a child process, so custody is connection-scoped: the custodian holds the connection that can open turns, and endSession on the runtime port releases the hold without deleting the thread.
The pattern is the same at both layers: custody is recorded as durable evidence before the effect, never inferred after it.
what custody does not prove
Honesty about the boundary is part of the design.
A lease is an agreement inside the Oompa control plane. It cannot stop a foreign process from resuming the same provider thread. Codex’s protocol explicitly rejoins a running thread, so a terminal or another app that resumes the conversation can become a concurrent controller; the docs say plainly that Oompa cannot prevent that provider-level race and the operator should pick one controller for writes. The lease also cannot prove that a hosted command reached the provider. That is what the command record and its ambiguous state are for, and it is a separate mechanism.
Oompa itself is early-stage software. The lease and fencing rules are exercised by a bounded in-memory simulation campaign (bun run test:simulation, with campaigns for leases, authority, and the reducer), which checks transitions, rejection inertness, and fencing monotonicity over finite schedules. That is real evidence about the state machine. It is not proof about Convex, the network, or provider behavior, and the codebase says so.
What the model does give you, if you are building your own agent runtime, is the transferable part: give every session exactly one writer, stamp that writer’s authority on every effect, make the authority cheap to invalidate, and let reads and submissions flow around the fence instead of through it.
sources
- oompa: the CLI and daemon this lesson draws on; the lease rules live in
src/cloud/leases.tsand the custody rule inAGENTS.md. - Oompa session adoption and process custody: the liveness and resume rules below the lease.
- Oompa provider portability: what the neutral transcript preserves across custody changes.