Skip to content
Chock
The daemon (20 of 23)

The daemon

chock daemon

The daemon owns sessions, and it is the only thing a frontend ever talks to.

It runs a child per session and never runs the agent loop itself. The daemon does not reimplement the loop, the sandbox, or the tool runner, so if chock run works, the daemon works. The tool path forks, and fork carries only the calling thread, so it needs a single threaded process. Each child is that process, the same chock run a person types by hand, which leaves this one free to use threads for its own sockets. A session that crashes takes down one child and not the daemon.

What it listens on

The default listener set is one address, the unix socket at <state dir>/daemon.sock. --socket <path> names a different one.

There is no TCP listener unless --host <addr> names one. The unix socket is the default because the kernel puts a credential on the connection: the daemon reads the peer’s uid and refuses every uid that is not its own. A peer the kernel will not name is refused too, because an absent answer is never a permissive answer.

A TCP connection carries no peer identity, so there is nothing on that transport to check, and Chock does no authentication of its own. Anything that can route to a --host address can drive the daemon. Put a reverse proxy such as Authelia in front of it. --host 0.0.0.0 is accepted unguarded, because a person who types it has chosen to be reachable.

--port with no --host is refused by name, rather than obeyed, because obeying it would open a listener nobody asked for. --port 0 asks the system for a port and prints it.

The interface is the log

The session log is the truth of a session, and replay from an offset already works, so the daemon serves the log and invents no message format. If a client needs something the events cannot say, the event types need a field.

The test of every verb is whether it would still work with the client on another machine. Nothing here hands out a path, and nothing here takes one except the project directory a person already typed.

hello chock-control <protocol number>
    -> ok chock-control <protocol number>

start  <project directory>\t<message>
    -> ok <session id>\t<log path>

adopt  <project directory>\t<session id>
    -> ok <session id>\t<log path>

read   <session id> <last event id>
    -> one line per event, then the connection closes.

list   <project directory>
    -> one line per session, <started at>\t<the row as JSON>.

watch  <project directory>\t<session id>\t<last event id>
    -> the header line, then one line per event, and it keeps sending as the
       session appends. Ends when the session does.

answer <project directory>\t<session id>\t<request id>\t<yes|no>
    -> ok answered

<last event id> means “I already have this one”, so a client that reconnects with the last identifier it saw resumes with no gap and no repeat.

Every connection opens with a number

The daemon can be on another machine, so the two ends are installed at two times and one of them is older. The client greets with the control protocol number it was built against, the daemon answers with its own, and both check the numbers and not the shape of the reply. They have to be equal. Nothing below the greeting is read or written until they are, so a daemon this client cannot speak to is never asked to do anything.

The number is the protocol’s own and is not the number chock --version prints. It moves when a client built against the old number would misread the new one, and at no other time. A field added to a session row is not such a change: every field has a default and an unknown one is ignored, so both directions already hold.

A refusal names both numbers, from either side, so a person knows which end to update. A client built before the greeting existed opens with a verb, which is not a greeting, and is told so.

The daemon cannot write into a session’s log, so answer lands only by that session’s own socket taking it. A connect that failed is reported as a failure and never as ok answered, because an approval nobody could deliver must not read as one that was given.

A browser in front of it

chock serve

chock serve puts a browser in front of a daemon. It owns nothing: it is a client of chock daemon, and every fact it shows came out of the daemon’s own API. It never reads a session log, never takes a lock, and never resolves a session directory.

--daemon <address> says where the daemon is, as unix:/path or host:port. The default is the socket in the state directory, or $CHOCK_DAEMON when that is set. Nothing in serve branches on whether that address is local, because in a hosted world the daemon is somewhere else.

Its own browser facing listener is a unix socket by default, serve.sock in the state directory. --socket <path> names another one. serve checks the peer’s user id on that socket and refuses anybody else with 403 Forbidden.

--host <addr> swaps the socket for a TCP listener. TCP carries no peer identity, so serve checks nobody there and authentication is the job of a proxy in front of it. A browser cannot speak a unix socket, but nginx, Caddy and Authelia all proxy to one, which is why the socket is the default.

A proxy that runs under its own account cannot open that socket, because the check is the peer’s user id. Run the proxy as the user that started chock serve, or give the proxy a --host 127.0.0.1 address and accept what that address means. A person who wants a browser pointed straight at Chock types the same --host 127.0.0.1. --port with no --host is refused by name.

Handing a session over

chock detach                # hand over the newest session of this project
chock detach <session id>

--daemon <address> names the daemon, as unix:/path or host:port, otherwise $CHOCK_DAEMON, otherwise the socket in the state directory. --port <n> survives as shorthand for 127.0.0.1:<n>, which only a chock daemon --host listens on. Naming both --daemon and --port is refused.

Ownership is the session log’s exclusive lock, so a handover is that lock changing hands and there is no transfer protocol. One process lets go, the daemon’s child takes the lock, and it folds the log to recover the state. That fold is not new machinery: compaction, chock usage, chock plan and chock sessions all rebuild state the same way, and the agent loop folds the log at its own start before it appends anything. The daemon runs chock run --adopt, which carries on from the conversation the log holds and adds no message of its own.

A session that is still running is asked, and it answers at its next turn boundary. The ask goes over a second unix socket beside the log, under the same rule the approval socket keeps: the socket carries a question and an answer and never a log write. It takes two round trips, and the second one is what makes a client that gave up safe. The session says it is ready, waits for the client to confirm, and only then stops. A client that pressed Ctrl-C in between never sends that confirm, so the session carries on and nothing about it changes.

A turn is as long as a model call plus its tool calls, so chock detach waits. --wait <seconds> bounds that, and defaults to 300. A session that does not answer in time keeps running, unchanged.

The workspace moves with the session. Every run writes a workspace.open event naming the directory it works in and the commit that checkout started at, which are the two facts a second process cannot work out for itself. A run that hands over leaves the directory on disk, and the next owner adopts the same checkout instead of building a new one from committed state. A linked worktree records nothing about the process that made it, so taking one costs no git command at all. The scratchpad moves the same way, because it is keyed on the session identifier.

Two things do not move, so a handover waits for them. A background command and a background subagent both live in the process that started them, and that process is what writes their record into the log. A session asked while it holds either one names the counts, keeps the ask open, and hands over at the first turn boundary after that work is recorded. It keeps running its turns the whole time, so the wait costs the session nothing.

--wait bounds each read of the exchange on its own, so a session that names background work gets one wait for the turn boundary and one for the work. At the bound chock detach says what the session is still holding and leaves it running, unchanged. Nothing was taken, so asking again costs nothing.

A session that ended some other abnormal way also keeps its workspace, and that one is still refused: chock detach names the kept workspace and points at chock workspace rather than leaving that work behind.

A workspace is taken over only when the log proves nobody is in it. The event that names it has to be older than the ending that handed it over, which a workspace the current owner opened never is. Without that test a chock run --continue against a session the daemon had just adopted would have taken the checkout that session was working in, and the first owner’s own teardown would then have removed it. --allow-dirty is refused on a workspace that moved, for the same reason: it copies your uncommitted files over the files in the workspace, and on an adopted one those are the agent’s own edits.

With no daemon listening, this refuses and says to start one. It never waits and never reports a handover that did not happen. For a running session the daemon is proved to be there before the session is asked, because the ask cannot be taken back: a session that agreed has ended, and finding out afterwards would leave it stopped for nothing. A daemon that goes away in the moment between says so by name, and names chock run --continue as the way to take the session back.

A detached session asks its questions the same way every other session does, over the unix socket beside its log. chock approve <session id> attaches to that socket directly, and the daemon’s answer verb reaches the same socket for a client that only speaks to the daemon. chock approve has to run on the machine that holds the session, because that socket is a unix socket and no remote transport for it is built. A client somewhere else uses the daemon.

A session that hands over exits 8, because the work is not over and it is not that process’s any more.