daemon

package
v0.8.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 35 Imported by: 0

Documentation

Overview

Package daemon owns ccdad's background process: where its files live, the singleton that decides whether one is running, and the detached spawn that starts one.

The layout is three files with no overlap in how they are used, and the split is not stylistic. Windows LockFileEx locks are MANDATORY rather than advisory, so a read overlapping a range another handle holds exclusively fails with ERROR_LOCK_VIOLATION. The universal Unix idiom — lock the pidfile, then write the pid into it — therefore inverts on Windows: a second process reading the pidfile gets a hard error, and naive handling reads that as "no daemon" and sends a supervisor into a respawn loop.

~/.ccdad/ccdad.lock    locked exclusively for process life; never written
                       (0 bytes forever), never read — try-locked only
~/.ccdad/ccdad.pid     never locked; truncate-then-write with a trailing
                       newline; read freely
~/.ccdad/status.json   never locked; temp + atomic rename per tick; read
                       freely

daemon.log is a fourth file the table above does not name. It is never locked and never read by the daemon itself; it exists here so the layout stays in one place, which is what `ccdad doctor` checks for drift against.

Index

Constants

View Source
const (
	LockFileName   = "ccdad.lock"
	PIDFileName    = "ccdad.pid"
	StatusFileName = "status.json"
	LogFileName    = "daemon.log"
)

The four basenames are exported so a reader that needs the NAME and not the path — `ccdad uninstall`, deciding whether a directory is a ccdad store — gets it from the package that owns it without resolving a home directory it does not need.

View Source
const (
	ProbeArg       = "probe"
	ProbeUUIDFlag  = "uuid"
	ProbeModelFlag = "model"
	ProbeForceFlag = "force"
)

ProbeArg and the three flag names are the command line one probe is run with. They live here for the same reason RunArg does: internal/cli imports internal/daemon and never the reverse, so this package is the only place a name can be shared by the command that DECLARES the flag and the code that SPELLS it. Two copies of a flag name is a rename that silently stops working.

The names are written without their leading dashes because that is the form cobra's Flags() takes; the caller that builds an argv adds them.

View Source
const ChildEnvVar = "CCDAD_DAEMON_CHILD"

ChildEnvVar marks a process ccdad started itself. It is half of the recursion guard, and it is the half that survives the child running something other than the hidden daemon entrypoint: the allow-list refuses to auto-start for `__daemon`, and this refuses for anything a daemon's own descendants ever run. A missing guard here is not a bug that degrades — the child is itself `ccdad <something>`, so it spawns as fast as the operating system allows.

View Source
const RunArg = "__daemon"

RunArg is the argument that tells a re-executed ccdad it is the daemon and must run in the foreground. Without it the child would auto-start a child of its own.

It lives here, not in internal/cli, and the direction is not negotiable: internal/cli imports internal/daemon, never the reverse, or autostart closes an import cycle the moment it lands. The leading underscores keep it out of the namespace a user could type by accident.

View Source
const StatusSchemaVersion = 1

StatusSchemaVersion is the version stamped into every document this binary writes. The `--json` contract is ADDITIVE: fields are added, never repurposed or removed, so a reader of any vintage can read a document of any vintage by ignoring what it does not recognise. Bumping this number is therefore not how a field is added — it is how a reader is told that something it may care about is new, and nothing in ccdad refuses a document on the strength of it.

Getting this right in v1 is not academic. Upgrading ccdad replaces the binary while the OLD daemon keeps running and keeps publishing, so a new CLI reads an old document until something stops that daemon; and the moment it is restarted, any still-running shell pipeline is an old CLI reading a new document. Both directions have to work on the day of the upgrade.

Variables

View Source
var ErrLocksUnsupported = errors.New("this filesystem does not support locks")

ErrLocksUnsupported reports that this filesystem cannot do locks at all — ENOLCK on an NFS or CIFS mount with no lock daemon, or a GOOS gofrs/flock has no implementation for. It is never folded into "not running": a supervisor gating on that would respawn forever. `ccdad doctor` exists to name this condition, which is why it is distinguishable rather than just an error.

View Source
var ErrNoShutdownListener = errors.New("nothing is listening for a shutdown request")

ErrNoShutdownListener reports that nothing is listening for a shutdown request against this store.

It exists because Windows answers that question directly and Unix cannot: OpenEvent returning ERROR_FILE_NOT_FOUND means no daemon ever created the event, which is a negative answer to a probe (exit 5 in the exit contract) rather than a failure. A signal sent to a pid has no equivalent — a pid either exists or does not, and neither says whether the process is listening.

View Source
var ErrSingletonHeld = errors.New("another ccdad daemon holds the singleton")

ErrSingletonHeld reports that another process holds the singleton — a daemon is already running. It is a sentinel rather than a plain error because the callers that matter have to tell it apart from "cannot determine": `ccdad daemon status` maps the three outcomes onto exits 0, 5 and 1, and `ccdad auto` without --once refuses on this one specifically rather than failing. Two processes executing switches would fight the cooldown and anti-flap state the engine persists to disk, so exactly one must lose.

Functions

func ChildEnv

func ChildEnv() ([]string, error)

ChildEnv is the environment a detached daemon must be started with.

The environment is INHERITED — PATH, HOME and the rest are what let the daemon find `claude` and resolve a home directory at all — with the three variables ccpath resolves AT CALL TIME pinned to what they resolved to here, and the marker above added.

Pinning is not tidiness. Spawn sets the child's working directory to the root of the volume the binary lives on, so a relative CCDAD_HOME or CLAUDE_CONFIG_DIR resolves against a DIFFERENT directory in the child than in the parent: the daemon flocks one file while the CLI probes another, and each invocation from each directory sees "no daemon" and starts one more.

Symlinks are resolved for the same reason and to a smaller end. flock is per-inode, so two spellings of one store already contend for the same lock; what they do not share is the path the daemon prints, the path it derives after leaving its birth directory, and the store it reports in a status document. Resolving once, here, is what keeps those from depending on how a shell happened to spell it.

What this does NOT do is decide whether a daemon should be started at all. A credential environment scoped to one terminal — which is what CLAUDE_SECURESTORAGE_CONFIG_DIR exists for — would make a daemon manage that terminal's credentials for the rest of its life, and pinning the resolved path only makes that permanent rather than preventing it. Refusing is the answer, and it belongs to the auto-start policy, which is the only caller that starts a daemon nobody asked for.

func ClearPID

func ClearPID() error

ClearPID empties the pidfile without removing it.

Removal is never correct. An absent pidfile means "no daemon has ever run against this store", and a shutdown that unlinks it forges that state — the same reason the singleton lock file is never unlinked. A zero-byte file says "a daemon ran here and is not running now", which is the truth.

func ForceShutdown

func ForceShutdown(pid int) error

ForceShutdown terminates the daemon at pid — and only if the process holding that pid is still the daemon that was recorded.

It is the LAST resort and never the first: the caller must have asked gracefully and waited. On Unix there is no such thing here at all, and that is deliberate rather than unfinished — a daemon ignoring SIGTERM is a bug the user has to be told about, and `kill -9` is one command away when they decide otherwise. Windows has no equivalent a user can safely reach for: `taskkill /F` takes a pid and performs no cross-check whatsoever, which is precisely the mistake this exists against.

Everything it needs to identify the daemon it recorded is read here rather than passed in, so a caller cannot assemble a target that skips a check.

func LockPath

func LockPath() (string, error)

LockPath is the singleton lock. It is never written and never read; the only operation on it is a try-lock, and it is NEVER unlinked — flock is per-inode, so delete-and-recreate lets two daemons each hold "the" lock on a different inode, and unlinking also erases the missing-file evidence that no daemon has ever started here.

func LogPath

func LogPath() (string, error)

LogPath is the daemon's log. It is never locked, and the daemon opens it itself rather than inheriting it from whoever spawned it — a parent-opened descriptor would survive a rename and leave the daemon writing into the rotated inode while the new file stays empty.

func PIDPath

func PIDPath() (string, error)

PIDPath is the pidfile. It is never locked — see the package comment for why locking it inverts on Windows.

func ProbeAvailable added in v0.3.0

func ProbeAvailable() error

ProbeAvailable reports whether this machine can run a probe at all.

Asked separately from SpawnProbe, and that separation is the point. A caller records the attempt against the account's six-hour budget BEFORE it spawns — otherwise a spawn that never starts leaves the account probe-due on the very next cadence, forever. A machine with no Claude Code on it is not a failed attempt, though: nothing was spent and nothing was tried, so it must not consume that budget or the account stays unknown for six hours after claude is finally installed. Asking first is what keeps those two apart.

func ReadPID

func ReadPID() (pid int, ok bool, err error)

ReadPID reports the recorded pid.

ok is false, with no error, for every state that is a legitimate "there is nothing to read here":

  • the file is absent — no daemon has ever run against this store, which is the same genuine evidence the singleton's missing lock file carries;
  • the file is zero bytes — a write is in flight, and this is the most likely torn state of all, because truncation is the first thing WritePID does;
  • the body has no trailing newline — a write is in flight and this is the half-written prefix of it.

Everything else is an error, deliberately, including a body that IS committed but does not parse. Folding corruption into "nothing to read" reproduces one layer down the exact hazard the singleton contract forbids: a supervisor cannot tell "no daemon" from "this store is damaged", so it respawns forever. `ccdad doctor` is the reader that needs to see it.

A returned pid is never liveness evidence. Only the singleton lock is. The process may have died and the number may have been recycled onto something unrelated, and Kill(pid, 0) answering proves only that SOME process has that pid, never that it is ours.

func RequestShutdown

func RequestShutdown(pid int) error

RequestShutdown asks the daemon at pid to stop, and returns as soon as the request is delivered.

The pid is NOT what makes this safe, and nothing here can make it safe on its own: ReadPID's contract says a recorded pid is never liveness evidence, and a pid the kernel has recycled belongs to an unrelated process this would terminate. The caller's guard is the singleton — `ccdad daemon stop` sends this only when SingletonHeld() has just answered yes, which is the one fact about a daemon that cannot be stale in the direction that matters. A daemon exiting between that probe and this call leaves a window Unix cannot close; it is two syscalls wide and it is the window every process supervisor lives with. Windows does not have to live with it, because the named event names the STORE rather than a slot in a pid table — the pid is not even used there.

Waiting for the daemon to actually go is the caller's job, and it must poll the SINGLETON rather than the pid: the kernel releases the lock when the process dies, and nothing else on the machine reports that without a race.

func Run

func Run(ctx context.Context, o Options) (err error)

Run is the daemon process, from taking the singleton to giving it back.

The order is the design. Startup:

  1. take the singleton — everything after this depends on being the only one;
  2. open daemon.log and point stderr at it, so a crash from here on leaves a trace instead of vanishing into the null device Spawn handed the child;
  3. take the credential-home claim, which is the OTHER axis: the singleton keeps two daemons out of one store, and this keeps two stores off one Claude Code login. It comes after the log rather than beside the singleton because its refusal is the one a user has to read, and before step 2 there is nowhere to write it;
  4. sweep the status temp files a previous daemon's interrupted renames left;
  5. write the pidfile;
  6. publish a first status, so a `ccdad status` racing the start sees a daemon rather than nothing.

Only ErrClaimed stops the daemon. Every other reason the claim could not be taken — a filesystem that cannot lock, a credential home that cannot be written — is logged and run through: refusing there would take ccdad away from every machine with a network home, a configuration that works today, to guard a hazard that needs a second store to exist at all. `ccdad doctor` is where the degraded state is named.

Shutdown is ONE path, and it is a stop channel rather than an exit. A handler that calls os.Exit is not theoretical damage: a tick killed mid-swap abandons Claude Code's three lock directories on disk, and cclock's stale windows are 60 s, 60 s and 15 s — so Claude Code's own token refresh wedges for up to a minute over a Ctrl-C. The loop finishes the tick in flight, the final document is published marked stopped, the pidfile is truncated, the log is closed and the singleton is released. The lock FILE is never removed: flock is per-inode, and delete-and-recreate lets two daemons each hold "the" lock on a different one.

func SingletonHeld

func SingletonHeld() (held bool, err error)

SingletonHeld reports whether a daemon is running.

It never returns (false, nil) on an I/O failure. That is the whole contract: a supervisor gating on this would respawn forever on a filesystem where locks do not work, so "cannot determine" has to be a third outcome rather than folding into "not running". `ccdad daemon status` spends the three on exits 0, 5 and 1.

It never creates the lock file. A missing file is genuine evidence that no daemon has ever started against this store, and a probe that manufactures it destroys that evidence permanently.

A missing store DIRECTORY answers "not running" too, for the same reason a missing lock file does — a directory that does not exist has no daemon in it. It is indistinguishable from a mistyped CCDAD_HOME at this layer: both produce a *fs.PathError satisfying os.ErrNotExist. "Your store points at nothing" is a configuration question, and `ccdad doctor` is where it gets answered; reporting "cannot determine" for a fresh install would be worse.

Held is not the same as held-by-someone-else. flock(2) is per open file description, so a process that already holds the singleton sees its own lock through a second descriptor exactly as it would see another process's. The answer "a daemon is running" is still correct.

func Spawn

func Spawn() error

Spawn starts a detached daemon from this binary, which is SpawnFrom("").

The parameter exists for `ccdad update`: it has just written a new binary and wants the process that comes back to be THAT file rather than whatever os.Executable resolves to a moment later. It is not required for correctness, and rule 3 above says why — os.Executable hands exec a path STRING that is re-resolved at fork time, so a rename-over is already invisible to a later spawn. What it removes is the last step of indirection between the bytes that were just verified and the process now running them.

func SpawnFrom added in v0.8.0

func SpawnFrom(exe string) error

SpawnFrom starts a detached daemon from exe and returns without waiting for it. exe may be "", which means "resolve it with os.Executable", and that is what Spawn below passes.

Three rules, all of which have their own failure mode:

  1. All three standard descriptors are redirected before Start. A child that inherits the parent's pipes keeps them open, so `V=$(ccdad which)` hangs forever waiting for an EOF that never comes. This matters more here than in most programs because the daemon auto-starts from ANY ccdad command, so every command in the tree would be affected — and the bug is invisible interactively, where the terminal is not a pipe.

  2. Release, never Wait. Wait blocks the CLI for the daemon's whole lifetime; omitting both leaks the process handle on Windows.

  3. os.Executable, never os.Args[0]. The latter may be a bare PATH name, or a path relative to a working directory this function is about to leave — cmd.Dir is set below, so a relative argv[0] resolves against the wrong directory and the spawn fails with an ENOENT naming a path that exists.

    An earlier version of this comment claimed that /proc/self/exe makes Linux re-exec the OLD inode after an in-place binary replacement. That is wrong, and the probe is easy: os.Executable returns a path STRING and exec re-resolves it at fork time, so after a rename-over the child is the new binary. What IS true is that once the binary has been unlinked, readlink yields "<path> (deleted)", Go strips that suffix and reports no error, and the failure surfaces one line later as an ENOENT. install.sh stops the daemon before replacing the binary for its own stated reason — otherwise the old daemon keeps running old code and holding the singleton — and not because of anything about inodes.

All three descriptors point at the null device, including stderr. Handing the child a log file opened HERE would look tidier and would break log rotation permanently: the daemon would keep writing into the renamed inode while the fresh daemon.log stayed empty. The daemon opens its own log, and redirecting its own stderr onto that log — so a panic, which goes straight to descriptor 2 without passing through any logger — is the log's owner's job.

The environment is inherited, because CCDAD_HOME and CLAUDE_CONFIG_DIR have to reach the daemon or it would manage a different store than the CLI that started it — but inherited through ChildEnv rather than wholesale, so the paths ccpath resolves at call time are pinned to what they resolved to HERE. cmd.Dir below is about to move the child to the root of the volume, which is what makes a relative override resolve to a different directory in the child than in the parent. That same inheritance is why auto-start must be suppressed under `go test`: an unsuppressed spawn detaches a daemon pinned to a t.TempDir() that is about to be deleted underneath it.

func SpawnProbe added in v0.3.0

func SpawnProbe(uuid, model string) error

SpawnProbe starts one `ccdad probe` and returns without waiting for it.

A separate PROCESS rather than a function call, and the reason is the import direction stated above RunArg. A probe's mechanics are `ccdad run`'s — an ephemeral credential home, the account's stored login seeded into it, the adopt-back that carries a rotated refresh token home before the directory is deleted — and all of that lives in internal/cli, which imports this package and never the reverse. Re-execing the same binary is what lets the daemon have that code rather than a second copy of it, and it buys two more things: a claude that hangs cannot stall the tick loop, and a probe that crashes takes nothing with it.

The child is NOT detached. A probe is the daemon's own errand and should die with it, unlike the daemon itself, which has to outlive the terminal it was born in. Waited for on a goroutine of its own for the same reason: not waiting leaves a zombie per probe until the daemon exits, and waiting here would put a whole Claude Code turn on a roughly 1 Hz tick loop. `ccdad probe` gives its claude a deadline of its own, so this goroutine cannot outlive it.

func StatusPath

func StatusPath() (string, error)

StatusPath is the per-tick status document.

func SweepStatusTemps

func SweepStatusTemps() error

SweepStatusTemps removes temp files left beside status.json, and beside the usage history, by a rename that never completed.

WriteFileAtomic's own comment calls an orphaned temp acceptable, and at the rate it is called elsewhere it is. At one write per second on Windows, where a scanner holding the temp file open is what strands it, "rare" becomes "daily". The daemon sweeps at startup, AFTER taking the singleton — which is what makes it safe for status.json, because the singleton is the proof that no other daemon is mid-rename.

The history is swept on a NARROWER argument, and the singleton is not it: `ccdad list --refresh` appends to the series from a process that holds no singleton, so a temp file there can belong to a live stranger just as usage.json's can. What differs is what each side costs. A history temp exists for the microseconds between one write and one rename, at most once per poll, and losing that race drops a single sample out of hundreds — while an orphan there is otherwise collected by nothing, because that file has no other sweeper. usage.json is written by every command that takes a reading, and a lost cache write costs a poll's worth of freshness for every reader at once, so its temps are still left alone.

func TailLog added in v0.5.0

func TailLog(n int) ([]string, error)

TailLog is the last n lines of the daemon log, read from a bounded window at the end of the file.

This is a SECOND reader beside the one `ccdad daemon logs` uses, and that is deliberate rather than an oversight. That one reads the whole file, which is exactly right for a command that takes a line count of zero meaning "all of it", and exactly wrong for a screen that refreshes on a timer. Neither of them publishes a number the other publishes — they answer "show me the log" and "show me the tail of it right now" — so the rule that keeps one figure in one place is not engaged here.

It opens and closes on every call and never holds the handle across refreshes. The daemon rotates by RENAMING, so a reader that kept its handle would go on reading the renamed inode with every line after the first rotation silently lost, forever. On Windows a held handle is worse than that: Go's open asks for FILE_SHARE_READ and FILE_SHARE_WRITE and not FILE_SHARE_DELETE, so the handle BLOCKS the rename outright and wedges rotation for as long as the screen is up.

A missing file is "no log yet" and not an error: a machine where no daemon has ever run is the ordinary state of a fresh install.

func WritePID

func WritePID(pid int) error

WritePID records a pid, in place, as truncate-then-write with a trailing newline.

It deliberately does NOT go through cclink.WriteFileAtomic, and that is the whole design rather than an oversight. The trailing newline is a commit marker, and a commit marker only earns its keep because the write is truncate-then-write: with a temp file and a rename there is no torn state for a reader to catch, the marker becomes dead code, and the next reader of this file deletes it as noise. Both halves have to stay, or neither means anything.

For the same reason there is no fsync. A sync would not make a torn write whole — it would only decide when the torn bytes reach the platter — and the marker already makes a torn write self-identifying to every reader.

Types

type AccountState

type AccountState string

AccountState is the engine's view of one account. It is a plain string on the wire and a reader must NOT switch on it exhaustively: a newer daemon may publish a state this binary has never heard of, and the additive contract says that is legal. Carry an unrecognised value through and render it.

const (
	// StateActive is the account Claude Code is currently logged in as.
	StateActive AccountState = "active"
	// StateCandidate is a healthy account the engine could switch to.
	StateCandidate AccountState = "candidate"
	// StateExhausted is over the threshold, and still polled: quota can be
	// granted or reset before the advertised timestamp.
	//
	// It does NOT mean the account is empty. The threshold is a number the user
	// chose -- or, under hover, a pace target derived from how far through its
	// window the account is -- and an account past it routinely has quota left.
	// StateEmpty is the other fact.
	StateExhausted AccountState = "exhausted"
	// StateEmpty is an account with a window that has nothing left in it at all.
	//
	// It is a separate value rather than a harder shade of exhausted because the
	// two drive different decisions: exhausted means the engine would rather not
	// spend this account, empty means it CANNOT -- the next prompt gets a 429.
	// Under hover the gap between them is wide, and publishing one word for both
	// is what had ccdad reporting five accounts "exhausted" while they held
	// between a fifth and a half of their week.
	StateEmpty AccountState = "empty"
	// StateQuarantined is held out of rotation by a dead refresh token.
	StateQuarantined AccountState = "quarantined"
	// StateDisabled was taken out of rotation by the user.
	StateDisabled AccountState = "disabled"
	// StateUnknown is an account whose usage could not be read. It is NOT an
	// empty account, and it must never render as 0%.
	StateUnknown AccountState = "unknown"
)

type AccountStatus

type AccountStatus struct {
	UUID  string       `json:"uuid"`
	State AccountState `json:"state,omitempty"`
	// NextPollAt is when the scheduler intends to poll this account next.
	NextPollAt time.Time `json:"nextPollAt,omitzero"`
	// LastPollAt is when it last did.
	LastPollAt time.Time `json:"lastPollAt,omitzero"`
	// LastPollError is why the last attempt failed, if it did. It is the engine's
	// own record and never a substitute for a quota reading: an account whose
	// poll failed is UNKNOWN, not empty.
	LastPollError string `json:"lastPollError,omitempty"`
}

AccountStatus is one account's engine state.

Note what is NOT here: utilization, resets, credit spend. See the authority note on Status.

type DaemonState

type DaemonState uint8

DaemonState is the answer to "is a daemon running", with the third outcome a probe that could not answer requires: "cannot tell" is not "no".

const (
	// DaemonUnknown is "cannot determine" — the lock could not be probed. It is
	// never folded into DaemonStopped: a supervisor gating on that would respawn
	// forever on a filesystem where locks do not work.
	DaemonUnknown DaemonState = iota
	// DaemonStopped is a definite no.
	DaemonStopped
	// DaemonRunning is a definite yes.
	DaemonRunning
)

func (DaemonState) String

func (s DaemonState) String() string

type Engine

type Engine struct {
	// AccessToken hands out an access token for one account, and FetchUsage
	// spends it. They are funcs rather than the concrete types so a test can
	// describe an endpoint's behaviour without one.
	AccessToken func(ctx context.Context, uuid string) (string, error)
	FetchUsage  func(ctx context.Context, accessToken string) (*usage.Snapshot, error)
	// Freshen refreshes one account's stored credential so a swap does not
	// install a login Claude Code would rotate on sight. Nil means a stale
	// credential is refused rather than repaired, which is the safe direction
	// and the one a test gets by default.
	Freshen func(ctx context.Context, uuid string) (cclink.Blob, error)
	// ResolveOwner names the account an access token belongs to, by asking the
	// profile endpoint. It is the oracle resolveLive turns on, and the only
	// thing allowed to say a login is somebody else's: nothing on disk can tell
	// a rotated managed account from an unmanaged one. Nil means every
	// unnameable login reads as unresolved, which stands the swap down — the
	// safe direction, and the one a test gets by default.
	ResolveOwner func(ctx context.Context, accessToken string) (string, error)
	// SpawnProbe starts one probe and returns without waiting for it. It is a
	// func for the same reason the two above are: starting a process is the
	// thing a test in this package cannot arrange. Nil means the package's own
	// SpawnProbe.
	SpawnProbe func(uuid, model string) error
	// Now is the clock, and Rand the jitter source the poll policy wants.
	Now  func() time.Time
	Rand func() float64
	// PollTimeout bounds one poll. Zero means defaultPollTimeout.
	PollTimeout time.Duration
	// Log records what a tick decided. Nil is silent.
	Log func(format string, a ...any)
	// contains filtered or unexported fields
}

Engine is the tick loop's body: the poller fleet, the scheduler, and the unattended switch.

One rule shapes the whole type. The tick NEVER waits on the network. It dispatches polls as goroutines and moves straight on to the decision, because Loop waits for the body to return — a tick that awaited a poll would stop publishing status and stop executing switches for as long as the endpoint took to answer, which on a laptop that just lost its connection is the request timeout, every tick, forever.

The second rule is the one cclock states in prose for the CLI and that this is the place it will actually be broken: no Claude Code lock is ever held across a usage fetch or a token refresh. The poller and the swap executor share this process, so the two are only separated by where the calls are made — internal/tokens releases Claude Code's refresh lock before it saves, and nothing here takes one at all.

func NewEngine

func NewEngine() *Engine

NewEngine wires the real token source, usage client and jitter source.

Rand is set HERE and nowhere else, because this is the only constructor a shipped binary reaches: pollpolicy takes its randomness as an argument to stay a pure function, so a nil source is not "no jitter", it is the midpoint sample -- and jitter(d, 0.5) is d exactly. Leaving it nil made every plus or minus ten percent guard in that package unreachable code while reading, in every comment, as though it were working.

math/rand/v2's package-level Float64 is safe for concurrent use, which this needs: polls run one goroutine per account.

func (*Engine) Config

func (e *Engine) Config() config.Config

Config is the auto-switch engine's knobs currently in force, and ConfigError whatever went wrong last reading them. Both are for the operator's benefit; the engine has already used the config either way.

func (*Engine) ConfigError

func (e *Engine) ConfigError() error

func (*Engine) Refresh

func (e *Engine) Refresh(ctx context.Context, s *store.Store, want []store.Account,
	cfg config.Config, active string) []RefreshResult

Refresh is `ccdad list --refresh`: one pass over `want`, taking a reading for every account allowed to have one taken, and returning when they are all in.

It is the SAME poller the daemon's tick dispatches — the same token source, the same commit, the same poll-policy cadence written back into the same cache — and that is the point rather than an economy. Two implementations of "record a poll" would compute two schedules, and the promise that `list` and `status --json` can never disagree only survives while one number has one writer.

What differs from the tick is the GATE, and only the gate. A tick polls what its own cadence says is due; a hand pressing a button is not on a cadence, so the poll policy holds it to serveTTL and to whatever floor a 429 has earned, and to nothing else. Honouring nextPollAt as well would make the flag useless in the one situation it exists for — no daemon running, a reading four minutes old, and a candidate's ten-minute cadence with nothing to advance it.

The caller's Engine must not be ticking. Nothing here takes the in-flight claim, because the CLI's Engine only ever runs this: a second poller inside ONE process is a case the CLI cannot produce, and a branch nothing can reach is worse than no branch. Across processes there is no claim to take anyway — the daemon is a different process, serveTTL keeps the overlap to a sliver, and the cache's own cross-process lock is what makes the two writes safe.

func (*Engine) Snapshot

func (e *Engine) Snapshot() Status

Snapshot is the engine state daemon.Run publishes.

The poll times are overlaid HERE rather than baked in when the tick published, and that is not tidiness: the tick does not wait for the polls it dispatched, so a document frozen at publish time would report every account's last poll one tick late, forever. What the tick decided is a tick-scoped fact; when an account was last reached is not.

func (*Engine) Tick

func (e *Engine) Tick(ctx context.Context) error

Tick is one iteration of the tick loop.

func (*Engine) Wait

func (e *Engine) Wait()

Wait blocks until every dispatched poll has finished. Shutdown calls it so a poll's cache write cannot land after the final status document was published.

type Logger

type Logger struct {
	// contains filtered or unexported fields
}

Logger is the daemon's log file.

It is the fourth file in the store and the one the three-file table in this package's doc comment deliberately does not cover, because it has none of those constraints: it is NEVER locked and NEVER read by the daemon itself. `ccdad daemon logs` reads it, and a reader competing with a rotation is a reader's problem — nothing here waits for one.

One rule for whoever writes that reader: on Windows a handle opened without FILE_SHARE_DELETE BLOCKS the rename below, and Go does NOT pass share-delete — os.Open and os.OpenFile both go through syscall.Open, which asks for FILE_SHARE_READ and FILE_SHARE_WRITE only. So a tail-follow that HOLDS the file wedges rotation for as long as it is attached; one that opens and closes per poll, as `ccdad daemon logs --follow` does, only narrows the window. Neither side can assume it wins the race, which is why the rotation below retries and the follower treats a retryable open failure as "next poll".

The daemon opens this file ITSELF rather than inheriting a descriptor from whoever spawned it, and that is the whole reason Spawn hands the child /dev/null on all three descriptors. A parent-opened descriptor survives the rename that rotation performs, so the daemon would keep appending to the rotated inode while the fresh daemon.log stayed 0 bytes — every line after the first rotation silently discarded, forever.

func OpenLog

func OpenLog() (*Logger, error)

OpenLog opens the daemon log with the size policy above.

func (*Logger) CaptureStderr

func (l *Logger) CaptureStderr() error

CaptureStderr points file descriptor 2 at the log.

This is not decoration. A panic and a runtime fatal go STRAIGHT to descriptor 2 without passing through any logger, and Spawn hands the child /dev/null on all three descriptors — so without this, the only trace a crash will ever leave is thrown away. The job belongs to whoever owns the log, which is this type, because rotation has to carry the redirect over to the new file.

func (*Logger) Close

func (l *Logger) Close() error

Close releases the file. It does not restore stderr: the process is on its way out, and a daemon whose last act is to redirect its own crash output away from the log is a daemon whose crash nobody sees.

func (*Logger) Printf

func (l *Logger) Printf(format string, a ...any)

Printf appends one line.

It reports nothing. A daemon that cannot write its log has no better channel to say so on — the alternative is a write error on every line for the rest of the process's life, which is noise rather than information.

func (*Logger) RotateIfLarge

func (l *Logger) RotateIfLarge() (bool, error)

RotateIfLarge rotates when the log has grown past the cap, and reports whether it did.

type Loop

type Loop struct {
	// Interval is the tick cadence. Zero means tickInterval.
	Interval time.Duration
	// Tick is the body. A nil body is a loop that only keeps house.
	Tick func(context.Context) error
	// Log is where a tick error, a panic and a rotation are recorded.
	Log *Logger
	// RotateEvery is the wall-clock cadence of the log rotation check. Zero
	// means rotateCheckInterval.
	RotateEvery time.Duration
	// Now is the clock. Zero means time.Now.
	Now func() time.Time
	// contains filtered or unexported fields
}

Loop is the daemon's 1 Hz tick loop.

The body is injected rather than written in. That is what let this land and be tested before the poller fleet, the scheduler and the switch executor existed: composed later, under the pressure of getting those three working, shutdown correctness is the first thing that gets cut.

Two rules the body inherits and this type cannot enforce:

  • Never hold a Claude Code lock across a network call. cclock says so in prose for the CLI, and the daemon is where it will actually be violated, because the poller and the switch executor share one process.
  • The body gets the loop's context. It is cancelled on shutdown as a courtesy, not as a kill: the loop always waits for the call to return.

func (*Loop) Panics

func (l *Loop) Panics() int

Panics is how many ticks have panicked over this loop's life.

func (*Loop) Run

func (l *Loop) Run(ctx context.Context) error

Run ticks until the context is cancelled, then returns.

It returns nil for a clean stop, and an error only when it has given up: an unbroken run of panicking ticks. It never calls os.Exit and it never releases anything it did not take — the singleton, the pidfile and the final status document belong to the caller, which is the one place shutdown ordering lives.

type Options

type Options struct {
	// Tick is the body of one iteration. It gets a context that is cancelled on
	// shutdown as a courtesy; the loop waits for it to return regardless.
	//
	// It must never hold a Claude Code lock across a network call. cclock says
	// so in prose for the CLI, and this is where it will actually be violated,
	// because the poller and the switch executor share this process.
	Tick func(context.Context) error
	// Snapshot is the engine state to publish. The process fills in what it
	// owns — pid, startedAt, the stopped flag — and stamps the time.
	Snapshot func() Status
	// Interval is the tick cadence. Zero means the tick loop's one second.
	Interval time.Duration
	// Now is the clock. Zero means time.Now.
	Now func() time.Time
	// Attach is handed the daemon's log once it is open, before the first tick.
	// The tick body is injected, so without this it has nowhere to record what
	// it decided — and a daemon that switches accounts silently is one nobody
	// can debug after the fact.
	Attach func(*Logger)
	// Drain is called after the loop has stopped and before the final status is
	// published. The tick body does not wait for the work it dispatches, so
	// this is what stops a poll landing in the cache after the document that
	// said the daemon had stopped.
	Drain func()
}

Options configures the daemon process.

Tick and Snapshot are injected rather than built here, because composing this process alongside the engine they belong to — the poller fleet, the scheduler, the switch executor — is how shutdown correctness gets cut. Both have working zero values, so the process is complete and testable without any of them. EngineOptions below is what wires in the real engine.

func EngineOptions

func EngineOptions() Options

EngineOptions is the Options the real daemon runs with: the tick loop's body, wired to the engine.

It lives here rather than in the CLI so the process and the thing it runs are composed in one place. internal/cli holds the seam that lets a test drive the hidden entrypoint without becoming a daemon; what that entrypoint runs is this.

type RefreshResult

type RefreshResult struct {
	Account store.Account
	State   RefreshState
	// At is when the endpoint may next be reached for this account. It is set
	// for the two states that are a WAIT rather than an answer — RefreshCached
	// and RefreshHeld — so the caller can say how long, instead of only that.
	At  time.Time
	Err error
}

RefreshResult is one account's outcome.

type RefreshState

type RefreshState int

RefreshState is what one account's hand-held refresh did.

const (
	// RefreshFetched: the endpoint answered and the reading is in the cache.
	RefreshFetched RefreshState = iota
	// RefreshCached: a reading younger than serveTTL was served as it stood.
	RefreshCached
	// RefreshHeld: a 429's floor is still in force.
	RefreshHeld
	// RefreshUnpollable: there is no OAuth grant to poll with.
	RefreshUnpollable
	// RefreshFailed: the attempt was made and did not produce a reading.
	RefreshFailed
)

func (RefreshState) String

func (s RefreshState) String() string

type Report

type Report struct {
	State     DaemonState
	Status    Status
	HasStatus bool
	// StatusErr is why the document could not be read, when one existed. It is
	// carried rather than returned because a damaged status file must not cost
	// the caller the liveness answer, which is the part a dashboard cannot
	// degrade without. `ccdad doctor` is what prints it.
	StatusErr error
}

Report is what the published document says, together with whether the daemon that wrote it is still there.

func Observe

func Observe() (Report, error)

Observe reads the published document and decides, against the singleton, whether it describes a daemon that is still alive.

The cross-check is the point. A crashed daemon leaves a perfectly valid status.json behind whose numbers then age in silence, and nothing inside the document can report that — not generatedAt, which the unchanged-bytes skip makes a change stamp rather than a heartbeat, and not pid, which may have been recycled onto an unrelated process. Only the singleton knows, because the kernel releases it when the process dies.

An error means the probe could not answer. State is DaemonUnknown then, and the document is still reported: it is the liveness verdict that is missing, not the contents.

type Singleton

type Singleton struct {
	// contains filtered or unexported fields
}

Singleton is a held singleton lock. It is released when the process exits, by the kernel, which is the property that makes it the sole authority on whether a daemon is running: no staleness heuristic can be wrong about it.

func AcquireSingleton

func AcquireSingleton() (*Singleton, error)

AcquireSingleton takes the singleton, or reports why it could not.

A lost race is ErrSingletonHeld and nothing else, so a caller can tell it from a filesystem that cannot lock. It retries first: a probe momentarily OWNS the lock it reads, so a daemon starting alongside `ccdad daemon status` can lose a race it should win.

The lock file is created here and never unlinked — not on shutdown, not by uninstall, not by a test teardown. flock is per-inode, so delete-and-recreate lets two daemons each hold "the" lock on a different inode.

func (*Singleton) Release

func (s *Singleton) Release() error

Release gives the singleton back. It is safe on a nil receiver and safe to call twice, so a shutdown path can call it without tracking whether some earlier path already did.

It unlocks; it never removes the file.

type Status

type Status struct {
	SchemaVersion int       `json:"schemaVersion"`
	GeneratedAt   time.Time `json:"generatedAt"`
	// PID is informational. It is never liveness evidence: the process may have
	// died and the number may have been recycled onto something unrelated.
	PID int `json:"pid"`
	// CredentialHome is the Claude Code credential home this daemon resolved at
	// startup — the directory whose .credentials.json it rewrites.
	//
	// It is here because it is a fact about the daemon PROCESS and nothing else
	// records it, which is this document's rule for what it owns. A daemon
	// started from a shell that resolved a credential home of its own -- which
	// CLAUDE_SECURESTORAGE_CONFIG_DIR decides when it is defined and
	// CLAUDE_CONFIG_DIR decides otherwise -- manages that directory rather than
	// ~/.claude for the rest of its life; the daemon is behaving correctly and
	// every other file on the machine looks normal, so a reader comparing this
	// against its own resolution is the only way anyone finds out. `ccdad
	// doctor` is that reader, in its credential-home row.
	//
	// `ccdad run --full-profile` used to be the example here and is no longer
	// reachable: auto-start refuses inside a `ccdad run` session, and the daemon
	// verbs that would start one by hand are refused there too. An override the
	// user set themselves is deliberately still allowed, which is what keeps
	// this field load-bearing.
	CredentialHome string `json:"credentialHome,omitempty"`
	// StartedAt is when this daemon acquired the singleton.
	StartedAt time.Time `json:"startedAt,omitzero"`
	// Stopped marks the final document a daemon writes on its way out. It is
	// what separates a clean shutdown from a crash: both leave a valid file
	// behind and a free singleton, and only this flag says which happened.
	Stopped bool `json:"stopped,omitempty"`
	// ActiveUUID is the account the engine last observed Claude Code using.
	ActiveUUID   string          `json:"activeUuid,omitempty"`
	LastSwitchAt time.Time       `json:"lastSwitchAt,omitzero"`
	LastSwitchTo string          `json:"lastSwitchTo,omitempty"`
	Accounts     []AccountStatus `json:"accounts,omitempty"`
}

Status is the document the daemon publishes, and the only thing it publishes.

Which file is authoritative

`ccdad list` and `ccdad status --json` can never disagree, because the daemon and the CLI read the same cache. That is a claim about usage.json, not about this file, and it only stays true if it is enforced here: if this document also carried utilization percentages, a reader taking quota from status.json while `list` takes it from usage.json would disagree with itself mid-tick, for no reason other than which file it happened to open.

So the rule is that every field has exactly ONE authoritative file:

  • quota — utilization, window resets, credit spend — is usage.json's, and both `list` and `status` read it from there;
  • account identity, alias, kind and the disabled flag are accounts.toml's;
  • daemon and engine state — is a daemon alive, what did it decide, when does it next intend to poll — is this file's, and nothing else records it.

No number is read from two places, so two commands cannot disagree about one. A field added here later has to answer the same question first.

generatedAt is not a heartbeat

The writer skips a write whose content is unchanged, so GeneratedAt advances only when something the daemon publishes actually changed. On an idle daemon it goes stale on purpose. It is a change stamp, and a reader that treats it as liveness will call a working daemon dead. Liveness comes from the singleton and from nowhere else — see Observe.

func ReadStatus

func ReadStatus() (Status, bool, error)

ReadStatus reads the published document. ok is false with no error when there is nothing to read, which is the ordinary state of a machine where no daemon has ever run.

A document that exists but does not parse IS an error. Folding it into "nothing to read" would hide a store that is damaged behind a state that is normal, which is the same mistake the pidfile reader refuses to make.

Unknown fields are ignored, which encoding/json does by default and which TestReadStatusIgnoresUnknownFields pins deliberately: a DisallowUnknownFields "hardening" here would break every older reader the first time a field is added, and it is one line away at all times.

It takes NO LOCK, and must not grow one. The three-file store layout in this package's doc comment puts this file in the never-locked column, and on Windows LockFileEx locks are MANDATORY: a reader holding one makes the daemon's next rename fail outright. The write is a rename, so a reader sees one whole version of the document or another — and catching the pre-rename inode is legitimate rather than something to guard against.

type StatusWriter

type StatusWriter struct {
	// contains filtered or unexported fields
}

StatusWriter publishes Status documents and skips the ones that would change nothing.

The skip is the whole reason this is a type rather than a function. The tick loop runs at 1 Hz — about 86,400 writes a day — and cclink.WriteFileAtomic creates a fresh temp sibling, fsyncs it and renames it every time. That is exactly right for a credential file and far too heavy for a cache that mostly republishes the same bytes. Comparing against what was last published turns an idle daemon's cost into one stat per second.

It is NOT safe for concurrent use. One daemon publishes, from its tick.

func NewStatusWriter

func NewStatusWriter() *StatusWriter

NewStatusWriter returns a writer that has published nothing, so its first Write always writes.

func (*StatusWriter) Write

func (w *StatusWriter) Write(s Status, now time.Time) (bool, error)

Write publishes s, stamping the schema version and — only when the content changed — the time. It reports whether anything was written.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL