Skip to content
C.W.K.
Stream
Lesson 04 of 05 · published

Adopting a Take

~16 min · process-tree, identity, reconciliation, cli, state-machines

Level 0Loose Note
0 XP0/36 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

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, surface compatibility, the declared host, and status unknown (never the column default idle, 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.

Refusing an untracked act does not prevent it; it only stops you seeing it. Record what happened, find who did it, and upgrade the record in place. Ownership that already exists is followed, never re-granted.

Code

From the domain's native.py: a CLI call finds the chat it runs inside (verbatim excerpt)·python
MAX_ANCESTORS = 16


def _parent(pid: int) -> int | None:
    try:
        out = subprocess.run(['/bin/ps', '-o', 'ppid=', '-p', str(pid)], capture_output=True, text=True, timeout=2).stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return None
    return int(out) if out.isdigit() else None


def detect_claude_session(registry: Path | None = None, pid: int | None = None) -> str | None:
    registry = registry or Path.home() / '.claude' / 'sessions'
    if not registry.is_dir():
        return None
    pid = os.getpid() if pid is None else pid
    for _ in range(MAX_ANCESTORS):
        entry = registry / f'{pid}.json'
        if entry.is_file():
            try:
                session = json.loads(entry.read_text()).get('sessionId')
            except (OSError, ValueError):
                return None
            return 'claude:' + session if isinstance(session, str) and session else None
        pid = _parent(pid)
        if not pid or pid <= 1:
            return None
    return None
From the engine's adopt_take: the same row, made managed in place (trimmed excerpt)·python
def run(conn):
    session=self.row(conn,'engine_sessions',session_id)
    if session['mode']!='observed' or session['surface']!='compatibility' or not session['native_id']:
        raise Conflict('Only a CLI taker\'s compatibility record can be adopted')
    clash=conn.execute('SELECT id FROM engine_sessions WHERE vendor=? AND host=? AND native_id=? AND id!=?',
                       (session['vendor'],identity['host'],session['native_id'],session_id)).fetchone()
    if clash:
        raise Conflict(f"Another enrollment already names this chat on {identity['host']}: {clash['id']}")
    ...  # UPDATE engine_sessions SET mode='managed', enrollment='active', generation=generation+1, ...
    generation=session['generation']
    dispatches=[r['dispatch_id'] for r in conn.execute("SELECT DISTINCT dispatch_id FROM engine_bindings WHERE session_id=? AND state='active'",(session_id,))]
    conn.execute("UPDATE engine_bindings SET generation=? WHERE session_id=? AND state='active'",(generation,session_id))
    # Envelopes queued while nobody watched the chat (a reply review for
    # Dad's reply) belong to it still; they move to the generation that
    # can now deliver them instead of blocking its adapter.
    conn.execute("UPDATE engine_outbox SET generation=? WHERE session_id=? AND state='pending'",(generation,session_id))
    for did in dispatches:
        ...  # mark the dispatch receipt adopted
        payload=self._cli_work_payload(conn,session,did)
        payload['scope']['project']=project
        self._enqueue(conn,session_id,generation,'work',payload)
    return {'session':session,'dispatches':dispatches}

External links

Exercise

Write a script that prints its own process ancestry: for each step up from its own pid, the pid and the command name, until pid 1. Run it from a plain terminal, then from inside whatever agent or editor you use for coding. Find the ancestor that represents your session, and decide what file or variable you could key on to recognise it the way Coop keys on its registration file.
Hint
ps -o ppid= -p <pid> gives the parent and ps -o comm= -p <pid> the command. Bound the walk (Coop stops at sixteen) so a strange process tree can never loop you forever, and stop at pid 1, which is launchd on macOS.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.