← Workstreams

Workstream: HandoffConversationIdentity

Status: Delivered (2026-08-19) · Component: Enable agentic swarms · Builds on: Handoff status colors/claims · Engine: AiCliHostSupervisor · Related: AiCliConversationSearch

Today claiming a handoff records only a free-form agent name. This workstream ties every claim (and status report) to the precise conversation doing the work — the harness-native session id — so the record answers not just who claimed it but which conversation, linkable to its live thread, its transcript, and eventually its spend.

Outcome (delivered 2026-08-19)

All five delivery-plan steps landed, each adversarially reviewed before merge:

Corrections discovered during implementation, now reflected in the design text below:

  1. A thread URL alone cannot form a reference — harness and nativeSessionId are required, so AICLI_SUPERVISOR_THREAD_URL acts as enrichment of a discovered session id rather than as a standalone rung of the identity chain; a thread URL with no session id yields no reference plus a notice.
  2. host is best-effort in two senses: the fleet snapshot carries no hostname, so read-time backfill uses the supervisor alias (falling back to its service address); the CLI supplies the local hostname only for identities it discovered from its own environment, never for a --conversation flag that may describe another machine.
  3. "Expose dead threads early" and "never downgrade a live heartbeat" cannot both drive live — resolved by keeping live monotonic (heartbeat OR conversation-ACTIVE) and exposing early death as a separate computed conversationActivity = MISSING, distinct from UNSUPERVISED (normal, never death) and from null (snapshot unreadable — unknown, never death).
  4. MISSING is reachable from bare session ids because the service records the first thread URL a claim's reference resolves to (derived state, not identity).
  5. The daemon mints its internal session id before spawn (the harness-native id otherwise wins after start), and the manager additionally publishes the launched id (ThreadRecord.supervisorSessionId) and builds thread URLs from it, so an injected URL is joinable on every spawn path including prewarm claims.

Assumption verification (codex)

The empirical CODEX_THREAD_ID check could not be completed on delivery day (the codex CLI was at its usage limit; the session id: startup header — fallback mechanism b — was re-verified). The discovery chain ships the CODEX_THREAD_ID rung regardless, which is harmless if codex does not export it; re-verify opportunistically and update this note with the result.

Goal

When an agent claims a handoff, the claim should carry a durable reference to the conversation the claim was made from. That gives the swarm three things the bare agent name cannot:

  1. Disambiguation — two sessions both claiming as fable-orchestrator become two distinguishable claims; a stale claim from a dead conversation is distinguishable from a live one.
  2. Navigation — the WUI can link a claim to its supervisor thread (live terminal) or, for finished conversations, to the transcript that AiCliConversationSearch plans to surface.
  3. Forensics and attribution — the conversation id durably names the harness-native transcript file, and it is the same key AiCliSpend uses for cost, so claims → session ids → spend gives cost-per-handoff with no new bookkeeping.

Current state (verified 2026-08-19)

  • Claims and conversations are disjoint mechanisms in the Handoff service. ClaimRecord is keyed by (handoffId, agentName) — free-form name, heartbeat-driven, 60-minute liveness window. ConversationRecord separately attaches a supervisor-manager thread URL (attachConversation), with ACTIVE/IDLE computed at read time from the manager's cached fleet snapshot. Neither record references the other, and attaching is a manual, optional step most sessions skip.
  • The join key already exists on the supervisor side. The manager's ThreadRecord carries both the supervisor-internal id and nativeSessionId — "the harness-assigned native session id (e.g. a Codex UUID)". A bare native session id can therefore be enriched into a full thread URL at read time by joining against the snapshot the service already caches for listConversationsJson.
  • Harnesses expose the conversation id to the processes they spawn. Claude Code exports CLAUDE_CODE_SESSION_ID into every Bash child (verified empirically on Claude Code 2.1.219). The codex native binary (0.145.0) contains CODEX_THREAD_ID; this workstream assumes codex exports it to exec'd shell commands — see Assumption to verify first. Additionally, codex exec prints session id: <uuid> in its startup header (verified), so an orchestrator can always capture a delegate's conversation id at dispatch time.
  • Transcript-parsing machinery already exists in the daemon's ConversationHistoryCatalog (AiCliHostSupervisorDaemonEmbedded), which scans both Claude roots (~/.claude/projects/<cwd-slug>/<session-uuid>.jsonl) and codex roots (~/.codex/sessions/YYYY/MM/DD/rollout-*-<uuid>.jsonl) and extracts sessionId/parentSessionId per file. Any future history-search fallback reuses it rather than rewriting it.

Assumption to verify first

This plan assumes codex exports CODEX_THREAD_ID (or an equivalent) to the shell commands it runs, symmetric with Claude Code's verified CLAUDE_CODE_SESSION_ID. The very first task is to verify it empirically (codex exec a command that prints env | grep CODEX_THREAD_ID, and the same inside an interactive session).

If the assumption is refuted, the codex direct path degrades gracefully rather than sinking the design: everything else in this plan stands, and codex conversations get their ids from (a) the supervisor env injection below for supervised sessions, (b) the session id: startup header captured by whoever launches codex exec, exported into the delegate's environment by the launcher, or (c) explicit --conversation. Only unsupervised interactive codex sessions would lack automatic association.

Design

The canonical conversation reference

A claim's conversation identity is a small structured reference:

Field Meaning Required
harness claude or codex (matching ConversationHistoryCatalog's vocabulary) yes
nativeSessionId The harness-assigned session UUID — the universal, durable key that names the transcript file yes
host Hostname the conversation runs on, best-effort no
threadUrl The supervisor-manager thread URL, when the session is supervisor-hosted no

The native session id is the canonical identity; it exists whether or not a supervisor is involved and outlives the session. The thread URL is a richer pointer that is stored when supplied and derived when not: at read time the service joins nativeSessionId against the manager snapshot's ThreadRecord.nativeSessionId and fills in the thread URL for supervised sessions. Enrichment is computed, never accepted as caller-supplied truth beyond what the caller actually knows.

Handoff service changes (HandoffApi / HandoffEmbedded / HandoffServiceServer)

  • claimHandoff, heartbeatClaim, and submitStatusReport each gain an optional conversation-reference parameter (additive and backwards-compatible, per the standing rule that protocol changes must be back-compatible and negotiated — old CLIs keep working unchanged).
  • ClaimRecord gains the nullable reference fields plus a computed, read-time-enriched threadUrl/activity view. StatusReportRecord gains the same nullable reference.
  • Claiming with a reference auto-attaches the conversation: when the reference resolves to a supervisor thread, the service creates (idempotently) the corresponding ConversationRecord and links claim ↔ conversation, merging today's two parallel mechanisms instead of adding a third. Releasing or completing drops the claim as today; the conversation attachment remains as history until detached.
  • Liveness gains a second signal. A claim is live if its heartbeat is within the 60-minute window or its referenced conversation is ACTIVE in the fleet snapshot. Conversely the WUI can flag early death: a claim whose referenced thread has vanished from the fleet renders as dead without waiting out the heartbeat window. Heartbeats are never overridden by conversation-IDLE — an agent quietly computing between messages is still working.
  • Claim identity becomes (handoffId, agentName, conversationRef?). The same agent name claiming from a second conversation creates a second claim rather than silently refreshing the first; re-claiming from the same conversation stays idempotent. A reference-less claim keeps today's exact semantics.

CLI changes (HandoffCli)

handoff-cli discovers the conversation reference automatically and passes it on claim, heartbeat, release-matching, and report. Discovery precedence, first hit wins:

  1. Explicit --conversation <harness:sessionId> / --thread <url> flags (always available as an override).
  2. AICLI_SUPERVISOR_THREAD_URL (injected by the supervisor daemon — see below).
  3. CLAUDE_CODE_SESSION_ID (⇒ harness=claude).
  4. CODEX_THREAD_ID (⇒ harness=codex) — the assumption above.
  5. Nothing: claim with agent name only, exactly as today, with a one-line notice that no conversation was associated.

No guessing. The CLI never scans transcript history to infer its conversation in this workstream: on a box running an orchestrator plus up to six concurrent delegates, a wrong association (a claim pointing at a different agent's conversation) is worse than no association, because the link will be trusted for forensics. If a history-search fallback is ever added later, it must (a) reuse ConversationHistoryCatalog parsing, (b) narrow by process ancestry (find the harness process above the CLI) and cwd, and (c) refuse and fall back to name-only when more than one candidate survives.

Supervisor integration (AiCliHostSupervisorDaemonEmbedded / AiCliSupervisorManagerEmbedded)

  • The daemon injects AICLI_SUPERVISOR_THREAD_URL (plus AICLI_SUPERVISOR_ID and the internal session id) into each PTY's environment at spawn. The internal id is known at spawn time; the native session id is assigned later and the manager already learns it (ThreadRecord.nativeSessionId), so the thread URL is the stable handle and enrichment fills the rest.
  • This also makes the create-handoff skill's currently-manual "conversation attach" step automatic for every supervised session.

WUI changes (HandoffWui)

  • Claim rows on the detail page show the conversation: harness badge, short session id, and — when enriched — a link to the manager thread (live terminal) with its ACTIVE/IDLE state. Dead-thread claims render as dead.
  • Status reports show which conversation filed them.
  • The card's yellow "claimed by" line may append the conversation's activity ("claimed by fable-orchestrator · thread active 3 min ago").

Ecosystem integration

  • create-handoff skill (claude-code-skills): document that claims and reports self-associate; drop the manual conversation attach instruction for supervised sessions; keep the explicit flags documented as overrides.
  • status-report skill: no procedural change — handoff-cli report picks the reference up from the environment automatically.
  • Spend attribution (follow-on, out of scope here): with native session ids stored on claims, joining against AiCliSpend's per-conversation costs gives cost-per-handoff; noted so the schema choice (native id as canonical key) is understood as deliberate.

Delivery plan

  1. Verify the codex assumption (small, first, gates nothing else): confirm CODEX_THREAD_ID export in codex exec and interactive sessions; record the result here and adjust step 3's discovery chain if refuted.
  2. Service: additive API params, record fields, claim↔conversation linking, snapshot join/enrichment, dual-signal liveness. End-to-end tests in HandoffEmbedded (claim with/without reference, enrichment against a fake manager snapshot, back-compat: an old-signature claim still works and renders).
  3. CLI: discovery chain + flags, wired through claim/heartbeat/report; tests drive discovery via injected environment.
  4. Daemon env injection + an integration test proving a PTY-spawned process sees the thread URL and a claim made from it round-trips to an enriched claim.
  5. WUI + skills: claim/report rendering, skill doc updates, screenshots in the WUI PR per the webapp-screenshots convention.

Phases 2–5 are sequential at the API boundary (each consumes the previous artifact) but 4 and 5 are independent of each other once 2 lands.

Resolved design decisions

  • Agent name stays, reference is optional forever. Humans at terminals and non-harness automation claim by name alone; conversation identity is an enrichment, never a requirement.
  • Native session id is canonical; thread URL is derived enrichment (stored only when the caller genuinely has it, e.g. from supervisor env injection).
  • A claim's conversation reference is immutable. A resumed or new session is a new conversation; claiming again from it adds/refreshes under the new reference. A report carrying a reference that matches no existing claim of that agent creates/refreshes the claim under that reference (reports are the heartbeat).
  • Absence over mis-association: no transcript-scanning heuristics in the CLI; refuse-on-ambiguity is a hard requirement for any future fallback.
  • Heartbeats are authoritative for liveness; conversation activity extends it and exposes dead threads early, never downgrades a live heartbeat.

Open questions

  • Should the service reject a claim whose nativeSessionId matches a thread record with a different harness than declared (evidence of a mislabeled reference), or store it as-given and surface the mismatch in the WUI? Leaning: store and surface — the service never second-guesses caller-supplied identity, it only enriches.
  • Whether release should require the same conversation reference it was claimed under, or release all of the agent's claims on the handoff. Leaning: reference-scoped when a reference is present, name-scoped otherwise.
  • Whether the fleet snapshot join should also backfill host on enrichment (the ThreadRecord knows the supervisor; the supervisor implies the host). Cheap, probably yes.