CrossTalk

CrossTalk design, draft 1.12 (2026-11-04)

Status: draft under operator review (greenfield gate step 4). Decisions are recorded in docs/decisions/ (ADR 0001–0011, all provisional and edited in place as the design moves; ADR 0005 is superseded by ADR 0006). Evidence for every “verified” claim is indexed in docs/spikes/README.md (spikes 1–4, cross-harness round trip; captured frames to be committed before go mod init).

Terms: harness = the agent CLI (Claude Code, Codex). Session = one running harness conversation (a Claude Code session or a Codex thread). Agent = the registered identity of a session. session_ref = the Claude session id or the Codex thread id. Shim = the crosstalk mcp process described in §2. herdr = the operator’s terminal pane manager and session restorer. cc-socks = Claude Code’s per-session socket directory (/tmp/cc-socks/<pid>.sock), which Claude scans for peers. A2A = Agent-to-Agent protocol. MCP = Model Context Protocol. uds = unix domain socket. pid = process id. F1–F18 = Codex design-review findings, summarized in the Changelog (1.1 and 1.3 entries). V1–V29, C1–C10, CQ1–CQ4 = council verdict items, listed in the 1.4 entry. R2 = the question whether Claude sessions accept a shim-published ~/.claude/sessions record as a native peer (§3, §12). Spike n = a pre-build experiment indexed in docs/spikes/README.md.

1. Goal and non-goals

Goal: any agent on this machine can send a message to any other by name and the receiver gets it inside its running session, without a human and without screen scraping, across harnesses.

Non-goals for v1: spawning or wrapping agent processes (ADR 0006; spawning stays with herdr and a separate delegate tool), cross-machine messaging (remote A2A is vNext), kind=control (stop/shutdown), isolation against hostile same-user processes.

2. Components

Component Count Owns
Shim one per harness MCP connection the crosstalk mcp process the harness starts for its MCP connection; serves one or more agents. Per agent: MCP tools to its model, A2A server+client, cc-socks-compatible uds ingress, injector into its harness, registration, journal (§5)
Directory none as a process discovery = every harness session from the harness’s own registry, merged with the shims’ descriptor directory (ADR 0003)
herdr optional panes and restore only; no v1 code uses it (§10)

The shim is a Go binary (ADR 0002). Every harness-specific behaviour (bind from request context, discover sessions, inject, status, render the inbound for the model, native reply address, optional native ingress) lives behind the harness.Harness interface in its own package (ADR 0008); internal/shim, internal/mcpserver and internal/a2a import no harness package, and adding a harness such as Gemini is one package plus one line in cmd/crosstalk within the extension points supported today (see the ADR 0008 narrowing note in §12). CrossTalk spawns and wraps nothing (ADR 0006): crosstalk mcp is installed once per harness as an ordinary MCP server entry (§9). harness.Harness embeds adapter.Adapter (Register, Inject, Status) and adds Name, Identify (per call, from the request context), Bind (at registration), Discover, Render and NativeReplyAddress; a harness that implements the optional harness.NativeIngress serves every agent’s uds_path (today Claude, whose protocol server is udsingress.NewIngress), and a harness that can prove a session ended while its MCP connection lives on implements the optional harness.Supersession (today Claude; §3). cmd/crosstalk lists the harnesses in trust order: Codex (_meta.threadId) before Claude (parent pid). adapter.Fake (scriptable status() and inject() outcomes: success, errors, state races, disconnects, blocking) is a v1 deliverable; the receiver chain and the injector failure branches are tested against it in go test.

3. Registration and discovery

Identity is bound per request, never per process (ADR 0006). For every tools/call the shim resolves the calling session:

One process may serve several Codex threads (connection pooling, MCP protocol 2026-07-28). The shim keeps one agent record per (harness, session_ref) and binds nothing to the first caller; every call is routed by its own evidence.

Agent identity (CQ9): agent_id = first 16 hex of sha256(<harness>:<session_ref>), stable for the session’s lifetime and independent of any display name. Storage, sockets, locks, journals, task stores, claims and reply-routing records key on agent_id; the agent name is a lookup alias. On the first bound call for an agent the shim writes ~/.local/state/crosstalk/agents/<agent_id>.json:

{ "agent_id": "<16 hex>", "name": "<agent name>", "harness": "claude|codex", "role": "primary|subagent", "owner": "<name>|null",
  "pid": <shim pid>, "harness_pid": <Claude session pid (= shim ppid) | null for Codex>, "harness_started_at": "...|null",
  "cwd": "...", "session_ref": "<claude session id | codex thread id>",
  "a2a_url": "http://127.0.0.1:<port>", "uds_path": "~/.local/state/crosstalk/sock/<agent_id>.sock",
  "instance_id": "<uuidv7, new per agent registration>", "harness_endpoint": "<codex app-server endpoint | claude socket path | null>",
  "harness_version": "<Codex app-server version from initialize | null>",
  "credential_ref": "<path to 0600 file holding this agent's A2A bearer token>",
  "installed": true, "started_at": "...", "protocol": 1 }

started_at and harness_started_at are the kernel process start times of the shim and of the Claude session process. role defaults to primary; role and owner change only through claim/release (§7).

Name (CQ8, CQ9): the harness session name (Claude name; Codex thread name) if free, else <harness>-<cwd basename>-<first 8 hex of agent_id>; if that is also taken, extend the suffix two hex at a time until free (agent_id is a hash of the whole session_ref, so identifiers that share a long common prefix, as the four spike-9 threads 01a1a3… do, still get distinct suffixes). A name is held under its own flock for the registration’s lifetime. A harness rename does not change agent_id, sockets, journals, claims or routing records; it updates the alias only: the new name is taken under its lock, the descriptor’s name is rewritten, the old alias is released. send_message(to) resolves aliases at send time; a message to a released alias is unknown with reason no_such_agent. Owner references in claims are by agent_id.

Binding (F2): harness_endpoint + session_ref bind an agent to exactly one conversation on exactly one server. A shim never selects a conversation by name, cwd, activity or “most recent”. Codex endpoint (CQ10): the shim queries each known endpoint (the managed control socket ~/.codex/app-server-control/app-server-control.sock plus any harness_endpoint recorded in a descriptor); an endpoint counts as hosting the thread only if thread/loaded/list includes it (thread/read alone may return stored history from a server that does not host the live conversation). Exactly one host → bind it as harness_endpoint. More than one → binding stays unresolved, inbound is unreachable with reason ambiguous_endpoint, journaled; never the first response. None → sending works, inbound is unreachable. canAcceptDirectInput is a separate per-delivery condition, not a binding input.

Credentials (F5): the bearer token lives only in the file credential_ref points to (0600). Descriptors and list_agents output never contain it; logs redact it.

Publish order and ownership (F8, C7): there are two locks: the agent record lock ~/.local/state/crosstalk/agents/<agent_id>.lock (one live registration per session) and the alias lock ~/.local/state/crosstalk/names/<name>.lock (one live holder per name), both taken first and held for the registration’s lifetime; descriptor check, publication, replacement and cleanup happen only under it. A held lock means a running owner: the newcomer registers under its fallback name (§3 Name rule) and its model is told so in the tool result, as “live” only when the holder is not superseded (see Superseded sessions below); a later call takes the name once it is free. Order, on the first bound call: take the lock, replay the journal (§5), bind uds + A2A listener, confirm the harness binding, then write the descriptor atomically (temp + rename). On exit the shim removes each descriptor only under its lock and only if the file’s instance_id is its own. Readers treat a descriptor as stale when the pid is dead or the pid is alive but the process start time does not match started_at (pid reuse). Known gap: a claim or rename that stalls in the journal past the retirement budget can still republish the retired descriptor when it resumes, since Publish checks only the agent lock (internal/registry/registry.go); it needs a journal stall over 5 s during a session switch, and the fix is to make name release terminal for Publish and Rename.

uds_path is agent_id-stable (CQ7, CQ9): it survives a shim restart, so a reply address embedded in an envelope before the restart still reaches the agent after it.

Discovery: list_agents enumerates every Claude session from ~/.claude/sessions/*.json and every loaded thread from thread/loaded/list + thread/read on each known app-server, merged with the descriptor directory (dead or reused pids pruned). Each row: name, harness, cwd, status, crosstalk: bool (a shim has bound this session), reachable: bool (Claude: its socket exists; Codex: status loaded and canAcceptDirectInput), plus role and owner for bound agents. A session without CrossTalk is listed and messageable (inform only, injected natively by the sender’s shim) but has no A2A card and cannot send. herdr is not consulted.

Claude-visible registration (R2) is out of v1 (1.6): a reply address travels in the envelope (from="uds:<uds_path>"), so Claude needs no ~/.claude/sessions record for a shim to receive native replies, and a pooled Codex shim has no single pid to publish under. Claude sessions see other agents through list_agents.

Superseded sessions: Claude Code’s /clear keeps the process and its shim and starts a new session id, so the old session’s record outlives its session. A harness that can prove this implements the optional harness.Supersession; Claude reports a session superseded when ~/.claude/sessions/<harness_pid>.json parses and names another session, and a missing or unreadable file is no evidence. Codex does not implement it: one process serves several live threads, and “same shim, different session” is not evidence. Three rules follow, all in the shared path:

A superseded session is not listed, and its agent_id and name are no_such_agent. A resume (claude --resume, /resume) is an ordinary registration of the old session_ref: same agent_id, same journal replayed (open asks, obligations, routes, deadlines), harness name if free, else fallback name with the “live” notice. A message sent to the session while retired fails at the sender; nothing is queued for it.

Harness-native registries the shim reads, never writes: ~/.claude/sessions/<pid>.json (Claude: self-binding through the parent pid, discovery and status; never sender identity on the uds ingress), app-server thread/loaded/list + thread/read (Codex).

4. Message

Internal normalized record (F12). This is what the receiver chain and the mailbox hold; it is not a wire format. On the A2A ingress it is decoded from the pinned SDK’s Message (standard messageId, role, parts, contextId, taskId); CrossTalk fields travel in metadata under the crosstalk.* namespace and are all optional. A plain text SendMessage with no metadata is valid and normalizes to kind: inform, delivery: immediate.

{ "message_id": "<uuidv7>", "in_reply_to": "<message_id>|null", "context_id": "<conversation>", "task_id": "<id>|null",
  "from": "<agent name>", "to": "<agent name>", "kind": "inform|command|ask", "delivery": "immediate|next_turn",
  "deadline_seconds": <int, ask only, 0 = none>,
  "hop_chain": ["<24-hex>", ...], "sent_at": "...", "parts": [{"type":"text","text":"..."}] }

ask is reply-required inform: it has inform’s permissions (§7) except that an unverified sender may inform but not ask and opens a reply obligation keyed by its message_id. kind is typed only as metadata.crosstalk.kind on an authenticated A2A request; absent = inform. The uds ingress has no kind encoding (§5). delivery drives the Codex adapter and travels as A2A metadata; it is a no-op on the Claude adapter, where the socket decides mid-turn vs next turn.

hop_chain uses Claude’s convention (24-hex sha256 prefixes, max 32) so a chain an A2A client supplies is compatible with Claude’s. The hop guard applies to A2A forwarding only: a native frame is never forwarded (ADR 0009) and carries no forwardable chain, so the receiver does not check a chain on a native frame. No code forwards a message today, so no code appends a hop; a reply gets a new message_id and sets in_reply_to.

Messaging limits and the nudge (wave 2, operator ruling 2026-11-04, ADR 0014). There is no limit on how many messages a session sends, and nothing bounds spend: a loop between two sessions is stopped by ending a session. Every send from the MCP send path starts a fresh one-element hop chain; nothing infers a forward from an earlier inbound message. hopCheck still guards the one case it can: an A2A client that supplies its own chain (a repeat of the receiver, or more than 32 entries, is rejected). Size, pending-work, open-ask limits and dedupe stay. The send result carries a nudge, not a guard: the shim counts the sends each agent makes to each peer (the peer’s agent_id, or harness:session_ref for a native session), counting every send except one that came back rejected or unreachable (and except an inform to a name that matches no session, which comes back unknown with reason no_such_agent; it reached no peer), and send_message, ask and reply all count (a retried reply is not recounted, and a model that keeps retrying an unreachable peer is never stopped, since those sends do not count). When a count reaches 30, 60, 120, 240 and so on, doubling (NudgeFirst), the result’s hint asks the model to check whether the exchange is making progress and says it may stop, or tell its user, if not; the send is still delivered, and the text appends to any hint already there. The count is in memory, per agent and per peer, and resets on restart. Each firing writes one nudge journal record (peer, count, time). The numbers are guesses, and whether models heed the hint is unmeasured. It misses fan-out (one message to many peers, each below the threshold), restarts (the count resets), failed sends (not counted), crosstalk send and any client that goes around the shim, native bounces, and Claude’s own SendMessage.

5. Ingress contracts (both feed one receiver)

A2A ingress (required for v1; operator requirement 2026-11-03). Each session is a first-class A2A agent served by its shim.

uds ingress (V1, V2, V4, V18, C3): Claude’s two-line protocol on uds_path: {"type":"auth","token":T} then {"type":"user","message":{"role":"user","content":C}}. T is the content of the CrossTalk-issued key at <uds_path minus .sock>.key (mode 0600, created on first start, refused if it is not a regular 0600 file); a connection is accepted in one of two shapes: (a) the auth line with a valid T, then the user line; or (b) a single type:"user" first line with no auth line, accepted only when the peer pid maps to exactly one registered descriptor with a matching start time (a verified sender), else closed with no state (a session without CrossTalk cannot send, test 26). Claude 2.1.317’s own SendMessage to a uds: address writes exactly one line such as {"msgV":1,"msg_id":"…","type":"user","message":{"role":"user","content":"<cross-session-message from=… hop-chain=… from-name=… from-mode=…>…</cross-session-message>"},"priority":"next","from":"uds:…"} and sends no auth line, and Claude’s own session socket performs no token check; unknown top-level fields are ignored, msg_id and from are kept as display-only. C may be a <cross-session-message ...> envelope. Sender identity: at accept() the shim reads the peer pid with getsockopt(SOL_LOCAL, LOCAL_PEERPID) (macOS, golang.org/x/sys/unix; SO_PEERCRED on Linux), reads that pid’s process start time, and maps it to a descriptor whose pid (start time = started_at) or harness_pid (start time = harness_started_at) matches. ~/.claude/sessions/<pid>.json is never consulted for sender identity. Unmapped pid, start-time mismatch, or a pid matching more than one descriptor (a pooled shim) → unverified. A pooled pid is never resolved by picking the first matching descriptor or by trusting from-name; it stays unverified. The native shape (b) gets no ack and no bytes back, only EOF once the receiver returns, on success and on every rejection: Claude’s sender treats any reply bytes as an error and only EOF as success (live, 2.1.317). The per-agent receiving socket identifies the recipient, never the sender. The uds ingress exists for native Claude senders; shim-to-shim traffic uses A2A, where identity is the per-agent bearer token and pooling is irrelevant. Since T is readable only by this user, an unverified sender is a same-user process not matched to a registered agent, consistent with the §1 non-goal. The uds ingress has no kind encoding beyond inform: a frame is inform; a frame that requests any other kind (ask or command, by a kind envelope attribute or a crosstalk.kind field) is rejected without injection, whatever its sender.

crosstalk send <name|agent_id> <text|-> is a client of shape (a) for plain scripts: it resolves one registered descriptor by name or agent_id (several of one name → error), reads the agent’s key file, writes the auth line and one user line with the text as content, and prints the ack (message_id, state, reason); exit 0 for accepted or queued. The script is not a registered agent, so the receiver sees an unverified sender and the message is an inform labelled unverified; it enters the one receiver chain through the uds ingress and is never injected by the CLI. It does not create a key (a missing key is an error) and does not reach native sessions that have no CrossTalk binding.

Two identities per inbound message (F4): the transport peer (the process on the socket: a Claude session pid, another shim, or unknown) and the original sender (the registered agent the message is from). On the A2A ingress both are the sending shim’s agent. On the uds ingress the transport peer is verified from the socket; the original sender equals it when the peer is a registered agent, and is otherwise unverified. A relay never upgrades the original sender’s identity beyond what the transport proves.

Native replies through a shim (F4, C8, ADR 0009): when shim B injects a message from A into Claude session B, the envelope’s from is B’s own uds socket and from-name is A, and it carries message-id so the reply tool can name the inbound message. A native SendMessage from session B to that address therefore reaches shim B as a frame with no correlation id. Shim B never forwards it: it journals the frame as bounced and injects a message from crosstalk back into session B saying the native message was not delivered, quoting its first line, and listing the open inbound messages (message_id, sender name, kind) it may answer with reply, or send_message otherwise. A bounce opens no route and is never itself bounced. Only the reply tool, naming the inbound message id, completes an ask and closes its route; an ask nobody answers ends at its deadline. (Live round 4 on 7bf18d1 showed the earlier sole-open-route guess completing an overlapping ask with an inform’s text.)

Native-message normalization (F6): a frame arriving over uds without our metadata is assigned a fresh message_id by the receiving shim, in_reply_to stays null unless the correlation above is unambiguous, and it is exempt from the message_id dedupe (identical repeated text is legal). Identified messages: authenticate first, then look up (sender, message_id); acceptance is recorded atomically with the journal write so two concurrent duplicates cannot both inject; a retry returns the prior state.

Journal (V8, C6, CQ4): one append-only JSONL file per agent at ~/.local/state/crosstalk/journal/<agent_id>.jsonl, written and fsynced before an ingress or tool acknowledges. Records: dedupe keys (verified sender, message_id, state); tasks (task_id, message_id, context_id, creating principal, kind, task state, delivery state, reason, ask reply text, deadline, and the A2A message the change appended to history; one record per task change); dispatch claims (message_id, dispatching); dispatch outcomes; reply-routing records (session, delivered message_id, original sender); reply-delivery obligations (§8 reply); claims on this agent (owner, claimed/released, §7). Every record carries the binding it was accepted under (instance_id, harness_endpoint, session_ref). Replay on agent registration, under the name lock: acknowledged acceptance is always recoverable; a partial trailing line is discarded; an unresolved dispatching record is never re-injected but reconciled (Codex: thread/turns/list on the recorded endpoint and thread, for message-specific evidence; Claude: no evidence is available, so it stays unknown); unresolved records surface through receive_message. Records bound to a different (harness_endpoint, session_ref) than the agent’s current binding are never injected or routed into the new conversation; they become unreachable and surface through receive_message. Records older than 24h are truncated, except unresolved ones (submitted, dispatching, unknown, recovered, open asks, undelivered replies), which are kept until resolved. A2A tasks are journal-backed (S12, 2026-11-03): the A2A server rebuilds its tasks from these records on start, drops terminal tasks older than the retention window, keeps open ones and re-arms their ask deadlines; a delivery outcome learned after the receive returned reaches the task through Server.Resolve(message_id, state, reason) (accepted/queued complete an inform or command, rejected/unreachable fail it, unknown leaves it working), which the wire layer applies at start from journaled dispatch outcomes and a later reconciler must call.

Admission (C9): limits, checked in the policy step before acceptance: message size (default 64 KiB of text parts), pending tasks per agent (default 256; this bounds unresolved A2A task ids, not every unresolved inbound message: continuations of an open task and native ingress occupy no new task, see §13 WA2), open asks per agent (default 32), concurrent default-wait SendMessage calls (default 64). Exceeding a limit returns rejected (A2A: a JSON-RPC error, no task created) and never evicts accepted work or its dedupe record.

Receiver, both ingresses, in order: authenticate → dedupe on (verified sender, message_id) for identified messages → hop_chain check (repeat or >32 → reject) → policy (§7, including admission) → inject (§6). Returns the internal {message_id, state}; each ingress encodes that into its own wire response (A2A: Task/error; uds: see the wire behaviour in §5).

6. Injector per harness

Each adapter below is wrapped by its package’s NewHarness (claude.NewHarness, codex.NewHarness, ADR 0008). The Claude harness’s NativeReplyAddress is the injecting agent’s uds_path, passed to Inject as Delivery.ReplyAddress and sent as the envelope from; Codex has none.

Claude adapter: inject = connect to the session’s /tmp/cc-socks/<pid>.sock, auth with the token from ~/.claude/sessions/<pid>.*.key, send one user line whose content is the reconstructed envelope <cross-session-message from="uds:<shim uds_path>" from-name="<from>">\n<escaped body>\n</cross-session-message>. Claude sends no acknowledgement of an injected line (spike 10): the connection staying open past the short grace period after both frames are written is accepted; EOF, reset or any other reply is unknown (CQ2); a failed first write is unreachable. Mid-turn delivery between tool calls is verified (spike 1). Next-turn delivery into an idle session is unverified and gated by spike 6 and test 19. delivery is a no-op on this adapter.

Codex adapter (ADR 0007): inject(delivery) uses the endpoint recorded in harness_endpoint at registration. The connection uses WebSocket framing and initializes with capabilities:{experimentalApi:false} before any delivery. The existing version gate and endpoint ownership checks remain.

7. Trust and policy

Relay (wave 2): The message notice does not cover relay: “any tool but CrossTalk” still lets a peer get a message relayed to a third session. Only the skill speaks to it, and the skill does not reliably load (0 of 6 in the replica runs), so the hole is open. Adding “or message a third session” to the notice was measured and reverted: on Haiku with mocks peer-wrapper-read fell from 6 of 6 to 4 of 9 with real file reads, while peer-relay already passed on the old text, so the clause cost what it was meant to protect and had no measured benefit; the 8b replica also ran on the old text.

Identity comes from transport (peer pid / A2A auth) matched to registration, never from envelope attributes. Envelope attributes are display only and are reconstructed by the receiver from the verified identity.

kind to a subagent to a primary
inform any registered agent; unverified senders allowed any; unverified allowed
ask verified senders only; an unverified ask is rejected (inform is allowed) verified senders only
command owner only any registered primary
control not in v1 not in v1
unknown kind rejected rejected

Claims (role and owner): every agent registers as primary with no owner. claim(name): any registered primary may claim an unclaimed registered agent; the claim is journaled in the target’s journal and the target is told by an injected inform; the target then is subagent with that owner. release(name): only the owner releases; the target is told the same way and returns to primary. An agent’s own model can never claim or release itself. A claim ends when the target’s session ends. The table above applies to the claimed state.

kind is a routing and labelling hint chosen by the sending model; it is not a trust boundary (V3). The control on what a delivered message can cause is the receiving session’s own harness permission model. The policy table exists to stop forged or unauthorised command from reaching the model, not to make command carry authority. ask never carries command authority (C3).

Rejected → state: rejected, never injected (F7): an unauthorized command is dropped, not downgraded to inform, because the text would carry the same request into the model. ask and command exist only as metadata.crosstalk.kind on an authenticated A2A request (C3); a uds frame is inform, and a uds frame that requests any other kind is rejected without injection (§5). Envelope attributes can cause rejection but never grant command. Continuations re-run this policy (§5). command authorizes work only within the receiving session’s existing operator permissions; it never changes approvals or settings. Envelope text nested inside a body is body text and never parsed as a second envelope. Peer text is escaped and labelled; it never enters developer/system instructions. Unverified senders never cause turn/start (§6; a scheduling rule, CQ3). The same-user credential model is accepted as not being isolation.

8. Tool surface (identical in both harnesses, served by crosstalk mcp)

Seven tools. An unbound call (§3) is served list_agents only; every other tool returns unbound.

States (V16): accepted = injected immediately with harness evidence (Claude socket connection open past the grace period, Codex named turn/start.toolOutput success with a turn identifier); queued = verified no-wake handoff to Codex model history (thread/inject_items success; next model request, not necessarily a new turn); rejected = policy, auth, admission, hop or kind failure, never injected; unreachable = definite harness refusal, pre-send failure, binding not loaded, systemError, or a journal record bound to another conversation; unknown = ambiguous failure after a dispatch claim, no resend; recovered = a uds message that passed the re-run checks on replay after a crash before its dispatch claim, never injected and not resent. None implies read or completed. recovered is a mailbox-entry state only, never a send_message return. bounced is a mailbox-entry state only (a native frame the shim bounced back to its own session), never a send_message return; dispatching is a journal/task-internal state only.

9. Lifecycle

Install (ADR 0006, ADR 0013, ADR 0016), once per harness, no wrapper and no per-session configuration: crosstalk install copies the binary to ~/.local/bin, installs the plugin from plugins/crosstalk/ (the skill only) and registers the server at the binary’s absolute path (docs/setup.md); the manual forms are:

Start: the harness connects the MCP server; that crosstalk mcp process is the shim. Protocol initialization creates no agent. Per-agent state (descriptor, agent_id-stable uds ~/.local/state/crosstalk/sock/<agent_id>.sock, journal, A2A listener on its own loopback port) is created lazily on the first bound call for that agent, in the §3 order. not_ready applies only between a tool call and completion of its agent’s binding, which should be immediate. A call whose name lock is held by a live owner is not served under that name.

Exit: when stdio closes the shim removes its own-instance descriptors, sockets and uds keys under their locks (a new key is created on the next start); journals and lock files are kept, since unlinking a lock file would let a later opener lock a different inode (§3). A session whose shim has exited is still listed and reachable natively for inbound (§3), but native replies to its name-stable uds_path fail to connect until the harness reconnects the MCP server and the next bound call re-registers the agent, which resumes its journal. Compaction and tool reconnection therefore re-register under the same name and session_ref; /clear is a new session_ref that takes over the name, and the old session’s agent is retired as above without the shim exiting (§3 Superseded sessions); a fork is a new session_ref and never inherits the parent’s binding. Records bound to an old session_ref are unreachable (§5).

The C7 name lock (§3), the journal rules and the admission limits (§5) apply per agent record. crosstalk capture (§6) is a diagnostic subcommand. Harness SessionStart hooks are v1.1 (§12).

Per-agent state in a shared process: each agent’s A2A listener binds 127.0.0.1:0 and is tied to exactly one agent record; the actual URL is published in the descriptor and not persisted across reconnects (a reconnect may pick another port and updates the descriptor). Task stores, journals, credentials and ownership locks are per agent and never merge because two agents share a process. Creation is lazy and atomic: concurrent first calls for one session reuse one listener, journal and lock. Closing one agent leaves sibling listeners intact; release changes role only. list_agents distinguishes a live shim process from an agent it currently serves (crosstalk: true means served now).

10. v1 scope

In (V17): §3 (no Claude-visible pair); §4; §5 including the A2A server (Agent Card, SendMessage, GetTask, CancelTask, ListTasks, tasks completing on delivery evidence), the journal and admission limits; §6 both adapters and crosstalk capture; §7 inform+ask+command and claim/release; §8 all seven tools; §9 with crosstalk mcp as the only process, per-request identity and Codex connection pooling; discovery of sessions without CrossTalk; adapter.Fake. Out: any wrapper or launcher (crosstalk run removed, ADR 0006), launch-time role/owner, control, delegate/spawn, cross-machine, SendStreamingMessage, push notifications, extended card, terminal (send-keys) fallback, turn-boundary task completion, harness SessionStart hooks.

11. Tests that gate “done”

  1. Claude→Claude via shim: send, native SendMessage reply is journaled bounced at the receiving shim with a bounce injected into the replying session; the reply tool then routes to the sender with in_reply_to resolved.
  2. Claude→Codex verified immediate while Codex is mid-turn: named tool output is consumed at the next model boundary; no peer userMessage item is created.
  3. Claude→Codex while idle, verified immediate sender: new turn starts with tool output. Deferred or unverified input: no-wake append succeeds (queued), idle remains idle, and a later independently started turn consumes the data.
  4. Codex→Claude via shim uds: arrives mid-turn with reconstructed envelope and verified sender.
  5. Native reply bounce (ADR 0009): with one or two outstanding asks from the same sender, a native Claude reply is journaled bounced, nothing is forwarded, the asks stay working with their routes open, and the session receives a bounce listing both message ids; a reply naming one id completes that ask only; a native answer to the bounce itself is bounced again, not delivered.
  6. Duplicate delivery (same message_id twice): second returns the prior state and causes no second injection.
  7. hop_chain loop (A2A ingress only, R15): A→B→A in a chain supplied by an A2A client is rejected at the repeat, and a chain longer than 32 is rejected. Native frames carry no forwardable chain (ADR 0009), so there is no native repeat or overflow case.
  8. Policy: a non-owner primary sends command to a subagent → rejected, never injected.
  9. Unverified uds sender (random pid) sends command → rejected without injection; inform accepted and labelled unverified; to an idle Codex session it is appended without wake, never turn/started.
  10. Harness restart: after both harnesses exit and are relaunched with the same session names (herdr restore or by hand), both agents re-register under the same names and test 2 passes again.
  11. Reply without a registry record: a shim with no ~/.claude/sessions entry injects into Claude with from="uds:<uds_path>"; Claude’s native SendMessage reply connects to that path and authenticates with the shim’s CrossTalk-issued key; the shim’s receiver journals it bounced and injects a bounce, and nothing is forwarded.

  12. Binding ambiguity (F2): two codex panes sharing a cwd, plus a third conversation on a different server; each agent receives only into its bound thread; a tool call with no binding evidence returns unbound.
  13. Relay routing (F4): A→B via B’s shim, B replies natively and the frame is journaled bounced with a bounce listing both open message ids, then B answers with the reply tool naming A’s message; the reply reaches A, with a third agent C also having messaged B in the same window; C never receives A’s reply and vice versa.
  14. Forged attributes (F7): envelope claiming from=”" over an unverified transport is rejected for command and reconstructed from the verified identity for inform; a command nested inside envelope body text does not become a command.
  15. Replacement vs old exit (F8): a second process binding an agent name while the first holds it → the second does not register under it; kill the first, let the second register, then let a stale first-process exit path run → the second’s descriptor survives.
  16. Injector failure branches (F10), run in go test against adapter.Fake: idle→active race during tool-output turn/start (joins active work), review/compact refusal → one no-wake append, malformed success → unknown, definite refusal or pre-send failure → unreachable, disconnect right after acceptance → state unknown and no resend, reconnect → no duplicate work. Concurrent duplicate ingress (same message_id on both ingresses at once) → exactly one injection.

  17. A2A conformance matrix, pinned a2a-go release with its helloworld client configured with the peer token: (a) the Agent Card validates with the SDK resolver, including version, securityRequirements and the JSON-RPC interface entry; (b) plain-text SendMessage without returnImmediately returns result.task only in a terminal state, completed with metadata.crosstalk.completion: "delivered", and the text appears inside the session; (c) a default-wait SendMessage whose dispatch ends unknown (fake adapter) never returns completed; (d) GetTask after SendMessage returns shows completed; (e) an ask sent with returnImmediately: true returns submitted at once and reaches completed with the reply as its final message after the session calls reply; (f) CancelTask on an unclaimed submitted task cancels it, and on a claimed or terminal task returns TaskNotCancelable; (g) SendMessage with a terminal taskId → UnsupportedOperation, with an unknown taskId → TaskNotFound; (h) ListTasks returns only the caller’s tasks; (i) a request with no or a wrong bearer token is refused; (j) each unimplemented method (streaming, push notifications, extended card) returns the error the pinned spec mandates. Status: the official SDK sample-client run has not been executed; this gate is open and untested.
  18. Codex endpoint pinning: a thread on a non-default app-server (codex --remote <other>) receives both immediate (tool output) and next_turn (raw history append) delivery from the same shim, and nothing reaches the default daemon.
  19. Idle Claude: a shim injects inform into an idle Claude session (no active turn, no pending tool call); Claude starts a turn and the model sees the envelope within 30 s. Repeated with an unverified sender, recording whether it wakes the session (CQ3).
  20. Restart dedupe: restart shim B; the sender retries the same message_id → returns the prior state, zero injections. A journal with a partial trailing line replays without it; an unresolved dispatching record is reconciled only with positive evidence, otherwise stays unknown (including raw Codex appends absent from client history), and is never re-injected.
  21. Restart reply routing (CQ7, real process restart, not an in-process reload): kill shim B between injection into Claude B and Claude B’s native reply, with Claude B still running; a native reply fails to connect until Claude B reconnects the MCP server; after the reconnect and B’s next bound call, a native reply to the pre-crash uds_path is journaled bounced and a bounce is injected (it is not routed), and the reply tool for the pre-crash message routes to A. Kill and restart both shim B and Claude B → the pending reply is unreachable, never injected into the new conversation. Restart shim B between reply completing the task and forwarding to A → the reply reaches A exactly once. Restart under the same name bound to a different conversation → no pending work or reply is injected into the new conversation; those records are unreachable in receive_message.
  22. Cancel race (C5): with adapter.Fake blocking between dequeue and the harness write, CancelTask issued in that window either succeeds with zero fake writes or returns TaskNotCancelable with exactly one write; never canceled and written.
  23. Continuation and authority (C3, V3): registered principal X sends SendMessage(taskId=T) for a task created by Y → TaskNotFound, nothing injected; Y’s continuation of T is policy-checked again (a command continuation to a subagent Y does not own → rejected, not injected); an ask from a non-owner to a subagent is accepted with inform permissions and gains no command authority; a command from a registered primary to another primary gains no approval or setting the receiving session did not already have (a gated action still prompts).
  24. Concurrent startup (C7): two processes binding one name at once → exactly one registers; a stale cleanup by the loser racing the winner’s publication never removes the winner’s descriptor.
  25. Admission (C9): stall a receiver (fake adapter blocking) and exceed each limit → deterministic rejected before acceptance; every previously accepted message is still accounted for in the journal and receive_message.
  26. Installation: plain claude and plain codex with the one MCP entry (§9); list_agents lists both plus a third session with no CrossTalk; a message to the third lands; the third cannot send.
  27. Codex pooling: a pooled Codex MCP connection serving two threads routes each tools/call by _meta.threadId and never cross-binds; a call with no _meta.threadId is served list_agents only.
  28. Claim/release: a primary claims an unclaimed agent and the target receives an injected inform and lists as subagent of the owner; a non-owner release is rejected; an agent claiming or releasing itself is rejected; claiming an already-claimed agent is rejected; the claim ends when the target’s session ends.

  29. Naming (CQ8): four Codex threads whose ids all begin 01a1a3 in one cwd, no harness names → four distinct fallback names; a fifth agent whose chosen name is held → suffix extended, never the same conflicting fallback.
  30. Rename (CQ9): rename a harness session while it has an outstanding ask delivered to it and an owner claim on it; a native reply to the pre-rename envelope address still reaches the shim and is journaled bounced, and the reply tool for the pre-rename message still routes; the claim still binds; after a disconnect and reconnect the same agent_id resumes the same journal; a message to the old alias is unknown/no_such_agent.
  31. Endpoint ownership (CQ10): stored history on endpoint A and live thread on endpoint B → bind B; the same thread loaded on two endpoints → ambiguous_endpoint, no binding.
  32. /clear (§3 Superseded sessions): a new Claude session on the same process and shim keeps the name with no notice; list_agents shows it once and not the old session; delivery by name reaches it; before its first call it is a native row and its name routes natively. A dead holder in another process never gets a “live” notice and yields the name within the sweep. A live holder still forces the suffix. Pooled Codex threads never evict each other. /resume of the old session takes the free name and its old ask is still answerable; resume in another process with the name held live gets the suffix, the “live” notice and its journal.
  33. Native rejection hints (§8): native_inform_only carries a hint that is true for reachable and unreachable targets; an ambiguous name is ambiguous_name with an agent_id hint.

12. Open questions

13. Review disposition: Codex adversarial review of draft 1.9 (2026-11-04)

Source: ~/Documents/Codex/2026-11-04/crosstalk-design-review-1.9.md (18 findings: 3 blocker, 10 major, 5 minor; 9 reproduced by overlay probes under crosstalk-review-evidence/). Severity: blocker = acknowledged work can become unrecoverable in an ordinary failure or retention cycle; major = a supported path has a functional, trust or operational defect; minor = contract or exposure cleanup. Dispositions below are the session’s proposals; the operator confirms or overrides them in the cleanup sweep. Nothing here is implemented yet.

# Sev Finding (short) Disposition Ruling / slice
R1 blocker Journal/task-store write failures are logged, not returned; acknowledgement can outrun durable state accept s15: required writes return errors and gate acknowledgement and dispatch; a failed write after a harness write yields unknown, never accepted
R2 blocker Task accepted and persisted before the dispatch claim; restart in that window leaves it submitted forever accept s16: on replay, a claim without an outcome is unknown and surfaces in the mailbox, never re-injected. A submitted dedupe record with no claim of the current conversation goes one of three ways. An A2A first message (it has a task record) stays submitted; a2a.Resume re-runs the chain from policy, dispatch included. A uds message re-runs the chain’s hop, policy and size checks and is not dispatched: a failure is rejected and stays out of the mailbox, a pass is recovered and appears in receive_message. An A2A continuation (its body carries task_id) stays submitted, neither resumed nor surfaced. Bound to another conversation: a uds message that fails the checks is rejected; a passing uds message, a first message and a continuation are unreachable and surface
R3 blocker Retention is per record key, so an open ask’s original message can be truncated while its route and task survive accept s17: retention keeps the full dependency set (original bodies, dedupe, route, obligation) of every open task, route and pending reply
R4 major Retrying reply injects a second answer accept one reply per inbound message: a repeat returns the first result; already_replied only if the text differs. Fix TestReplyRoutesThroughJournal, which pins the opposite
R5 major Reply replay resumes only forwarding; skips local-user; races descriptor publication accept reply is a staged transaction (obligation → complete task → close route → forward) resumed from its stage; replay starts after publish
R6 major A reply to a task continuation’s message id finds no ask accept index every continuation message id to its task; any of them completes it
R7 major Deadlines do not close routes; late replies report success; inform routes and the bounce list grow unbounded accept, §12 route lifetime decided ask routes close on terminal state and a late reply returns ask_closed; inform routes expire after 1 h; bounce lists the 10 newest and refers to receive_message for the rest
R8 major replied matches any journaled body with in_reply_to, even policy-rejected ones accept deadline suppression keys off a validated reply-receipt record written after admission, bound to the expected peer and conversation
R9 major Pending cap counts concurrent Receive calls, not unresolved tasks; admission runs after the task is created accept reserve against unresolved tasks before task creation; release on terminal or reconcile
R10 major Unbounded metadata persisted; 17 MiB record makes the journal unreadable accept 256 KiB request-body cap before decode; persisted record cap equal to the reader’s limit; both admission errors, no task created
WA1 major (wave-a review) Known-length requests over the body cap returned bare HTTP 413, which the client maps to unknown accept limitBody answers with a JSON-RPC invalid-request error; test pins rejected
WA2 minor (wave-a review) Pending cap bounds task ids, not all unresolved messages; continuations and native ingress are not counted ACCEPTED, not to be built (operator ruling 2026-11-04) no overall backlog bound: a native sender cannot see rejection, so a bound becomes silent loss
R11 major Codex discovery and Bind use different endpoint sets; null canAcceptDirectInput treated as true accept (Codex package, Codex implements) one normalized endpoint source; missing evidence is unreachable
R12 major Settle poll and transports lack per-request deadlines; UDS close waits on handlers before cancelling accept per-request deadlines inside the 2 s settle budget; cancel handler contexts before waiting; write deadlines on websocket
R13 major Claude envelope drops in_reply_to and kind accept add in-reply-to and kind attributes to the envelope; parity test across harnesses
R14 minor Agent Card publishes cwd and defaults to Access-Control-Allow-Origin: * accept, §12 decided drop cwd from the card; no CORS header
R15 minor Native hop chain is cleared before the guard accept as doc change the hop guard applies to A2A only; native frames never forward (ADR 0009), so the native guard is moot; tests 7 narrowed. The MCP send path starts a fresh chain per send, so the guard covers a client that supplies its own chain; model loops get the nudge in §4, not a limit
R16 minor Core selects one NativeIngress for all; Render is not the canonical path accept as doc change ADR 0008 text states: one native ingress protocol today, deferred-delivery and installation capabilities out of the interface; revisit when a second native protocol is chosen
R17 minor after pages only problem states, tool help says full journal accept as doc change tool help and §8 say: cursor over the full journal, default state filter always applies
R18 minor Normative text still describes removed mechanisms (Claude ack, turn/steer evidence, native routing tests 1/13/21/30, ADR 0001/0003/0004 clauses) accept reconcile in the cleanup sweep; ADRs 0001, 0003, 0004 and 0008 edited in place; conformance gate stays listed as open

Status 2026-11-04: R1 (s15), R2 (s16) and R3 (s17) are implemented on main with the probes turned into tests. Known gap left for the sweep: a resumed sender that is no longer registered is dispatched with its agent id only.

Operator rulings 2026-11-04. FIXED: the journal short-write wedge (a failed append truncates back to the pre-append size, so a torn line can no longer glue onto the next record; if the truncate fails the next append retries it or refuses) and R2 surfacing (on replay a uds-ingress dedupe record still submitted with no dispatch claim, no task record and no task_id re-runs the hop, policy and size checks from the same code as the receiver chain; the uds policy reads only the kind, so the journaled body is enough. A pass becomes recovered in the current conversation and unreachable in another; both appear in receive_message. A failure, or a record with no readable body, becomes rejected and stays out of the mailbox. Nothing is re-injected. recovered does not count as admitted, so legacyReceipt writes no receipt for it and the asker’s deadline notice still fires; a receipt the chain wrote before the crash stands. reply to a recovered inform returns no_such_message, since no route was opened, and from an unverified sender sender_unverified; the model answers with send_message to the entry’s from. A2A first messages always have a task record and stay with a2a.Resume. A stranded A2A continuation stays submitted and out of the mailbox: no resume, no surfacing, no admission. Its SendMessage runs the receiver inline, so the sender saw the connection fail and not success. Known gap: a retry with the same message id then returns the task without dispatching. In another conversation it becomes unreachable and surfaces with no check re-run, as a first message does, because the journal does not keep the sender’s role). ACCEPTED, not to be built:

Order of work when implementation resumes: s15–s17 (blockers) first, then R4–R6 together (reply transaction), then R7–R10, R12, R13; R11 goes to Codex; R14–R18 land with the cleanup sweep.

Changelog

Review disposition