Two Doors to One Worker
Everything in this quest so far began with an assignment: the engine invites an exact session, the session echoes a nonce, the claim commits, the work follows. But sessions do not only wait to be asked. A Pippa in a Claude Code chat can see an open todo and type todo take herself, through the Coop CLI. The architecture treats the CLI and the managed adapter as two doors to one worker, and this lesson is about making the second door lead to the same room.
It did not always. Until the review of 2026-09-30, a take without an exported PIPPA_COOP_SESSION was refused. That sounds strict. It was the opposite: refused sessions simply used the fast path that opens and closes a small item in one step, so the refusal produced less tracking, not more. Now a take with no session reference is still a take. The engine records it and writes a legacy obligation the chief reviews after three hours.
How a Chat Finds Itself
Better than tracking an anonymous take is knowing exactly which chat made it, without asking the model to remember anything. Claude Code registers every live session as ~/.claude/sessions/<pid>.json, carrying its sessionId. A CLI call typed inside that chat is a descendant of that process. So detect_claude_session walks up the parent chain with ps -o ppid=, at most sixteen steps, until it finds a pid with a registration file, and returns claude:<sessionId>. No hook, no flag, no instruction in a prompt.
Codex has no per-process registry, so a Codex session that wants the same mapping exports PIPPA_COOP_SESSION=codex:<thread id> itself. An exported value always wins over detection.
The CLI also names the Mac it ran on: PIPPA_COOP_HOST if set, otherwise the machine's fleet label. One subtlety the source spells out: over ssh, that is the Mac the command landed on, not the caller's.
What the Engine Writes
A take that names a session the engine has never enrolled gets a modest record in the same transaction as the take:
- a session row with mode
observed, surfacecompatibility, the declared host, and statusunknown(never the column defaultidle, which read as a worker with nothing to do); - an already-accepted dispatch whose receipt says where it came from, and a binding from the item to that session;
- an outcome obligation due in three hours, the legacy backstop, one hour longer than a managed accept gets.
A take typed inside a chat that is already a managed enrollment needs no new row: the binding sits under that enrollment and the work envelope goes out at once. The interesting case is the first one, because "observed" means nobody reads that chat's finals, delivers Dad's replies to it, or notices when it exits. The three-hour timer was its only follow-up.
The Adoption Pass
Since 2026-10-08 the assignment coordinator closes that gap. Every 60 seconds an adoption pass lists the CLI takers: observed compatibility rows for Claude or Codex with an active binding younger than 48 hours. It considers at most five per pass and tries each session at most once every ten minutes.
For each one it looks for that exact native session, one Mac at a time, in order: the host the CLI declared, then office, then every other fleet Mac, online ones first. The search goes through the same host objects as the rest of this track, so a laptop chat is found by the laptop's helper. What counts as found depends on the vendor. A Claude take needs a live chat: the id has to be in that Mac's session registry. A Codex take needs only a saved thread in that Mac's Codex thread list, because a Codex thread lives in one Mac's list and the Desktop app takes the envelope when it shows the chat.
The search stops at the first Mac that has the id. More than one match on that Mac is a conflict, logged and skipped. No match on any Mac is "not found", logged and tried again later. Exactly one match, on the first Mac that has any, is the only answer that moves anything. Notice what that check is: per Mac, not a census of the fleet. Once a Mac answers, nobody goes on to ask the later Macs whether they hold the same id too.
Then Engine.adopt_take runs in one command. It accepts only an observed compatibility row, and refuses if another enrollment already names that chat on that host (a refused adoption is not retried for 48 hours). Otherwise it flips the same row to managed, in place, at a new generation; moves the active bindings and every pending envelope to that generation; marks each dispatch receipt as adopted; and enqueues, for each of the chat's CLI dispatches, the work envelope it never had, scoped with source: cli-take. The coordinator installs an adapter for the chat and Dad gets one "chat enrolled" notification. The docstring's last line is the whole ethic: "Nothing is claimed or released here; ownership was the chat's already."
The chat is told the same thing in plainer words. A cli-take envelope opens with: "You took this work yourself through the Coop CLI, and Coop now follows this chat for it: the binding below is yours already, nothing new is being assigned."
The Generation Bump Bites Back
Moving the pending envelopes was not in the first version. The first Codex adoption (#326) still had a reply review queued at generation 1 from the hours when nobody watched the chat. Adoption bumped the session to generation 2; the new adapter, correctly, refused the old envelope as foreign, reported the session lost, and Dad received a false disconnect notice. Commit 27a7c67 moves the chat's pending envelopes to the adopted generation, makes an adapter consider only its own generation's envelopes, and has the engine cancel at start any pending envelope an older generation left behind. The fence was right; the migration had forgotten to carry the luggage across it.