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.
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.
| 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.
Identity is bound per request, never per process (ADR 0006). For every tools/call the shim resolves the calling session:
~/.claude/sessions/<ppid>.json gives pid, name, cwd, status, messagingSocketPath.params._meta.threadId, which Codex attaches to every tools/call (required; session_ref is the thread id). params._meta.sessionId is present on model-issued calls and absent on app-server mcpServer/tool/call frames (spike 9); it is optional supplementary metadata and never a fallback identity. Evidence (rust-v0.166.0 codex-rs/core/src/mcp_tool_call.rs; spike 9). MCP initialize carries no identity, the environment carries none, and cwd is not discriminating.unbound: only list_agents is served.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:
Registry.List, and so every lookup by name or agent_id, list_agents, and the uds sender check, skip a superseded descriptor. Its name stops resolving at once, and the replacement shows as one row: a native row (crosstalk: false) until its first bound call, then the bound row.HandlerCloseWait, 5 s). If any writer does not stop within it, the descriptor and alias lock still go, the journal stays open, and the agent lock stays until process exit, so a resume of that session is rejected/agent_held until the shim process exits.notice.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).
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.
A2A ingress (required for v1; operator requirement 2026-11-03). Each session is a first-class A2A agent served by its shim.
github.com/a2aproject/a2a-go/v2 at one tagged release in go.mod; the tag and the matching examples/helloworld/client revision used as conformance client are recorded here at pin time (candidate: v2.7.0, reported by review, unverified). Method names are the 1.x ones: SendMessage, GetTask, CancelTask, ListTasks. Streaming (SendStreamingMessage), push notifications and the extended card are not implemented and not advertised; each such method returns the error the pinned spec mandates for a disabled capability, fixed per method in the test 17 matrix./.well-known/agent-card.json, built with the SDK’s card type and validated by its resolver. Fields: name (agent name), description (“SendMessageResponse (result.task or result.message) inside the JSON-RPC success envelope, or a protocol error (InvalidParams, TaskNotFound, TaskNotCancelable, UnsupportedOperation), never the internal {message_id, state} pair. The A2A encoder and the Codex app-server encoder are separate code: Codex omits the JSON-RPC version header on the wire, A2A includes it.SendMessage creates a server-owned task, submitted on acceptance and working once dispatch is claimed (dispatching in the journal: an attempt is claimed, not delivered). inform/command: completed only on evidenced harness acceptance: Claude = the session-socket connection stays open past the short grace period after the frames are written (Claude sends no acknowledgement, spike 10); Codex = a success response to turn/start with the named tool output, or to thread/inject_items for queued delivery. Both are delivery evidence to the harness, not evidence the model read the message. failed on unreachable or rejected. A bare write whose connection closes or resets within the grace period, or any ambiguous failure, is unknown and leaves the task working until reconciled (journal, below). ask: completed only when the receiving session’s reply for it is correlated (§8); the reply text is the task’s final Message; failed with metadata.crosstalk.reason: "deadline" when deadline_seconds elapses first. Every task carries metadata.crosstalk.completion: "delivered": completion is delivery evidence, never model-read or work-done.SendMessage (configuration.returnImmediately false or absent) returns only when the task reaches a terminal or interrupted state. There is no server-timeout return of a non-terminal task; a timeout or dropped connection never manufactures completion. returnImmediately: true returns the submitted task at once; CrossTalk’s own send_message and ask always set it.SendMessage with taskId is allowed only from the principal that created the task. The appended message runs the full receiver chain as a new message with the same context_id, policy included; it never inherits the original’s kind or authorization. Unknown or another principal’s taskId → TaskNotFound; terminal taskId → UnsupportedOperation.CancelTask are serialized by one per-agent lock, and the claim is durably journaled under that lock before any harness write. CancelTask succeeds only while the task is submitted with no claim: the message is removed from the mailbox and the task is canceled, so a successful cancel means no harness write happened. Claimed (dispatching, working) or terminal → TaskNotCancelable; the shim never interrupts a harness turn. A codex queue handoff is an external side effect and is past the claim. GetTask, CancelTask, ListTasks (own tasks only) and continuation are allowed only to the principal that created the task.credential_ref) and a per-user shared peer token at ~/.local/state/crosstalk/peer.token (0600, created on first start). Any client presenting the peer token is the principal local-user; a shim presenting its own token is the principal of its registered agent. Own-token verification: receiver B resolves the claimed sender’s descriptor by name, reads the file at its credential_ref, and compares in constant time. This proves the sender can read a 0600 file of this user, the same strength as the peer token, accepted under the §1 non-goal; the registered-agent principal gives attribution and policy, not isolation. All holders of the peer token are one principal, local-user: “own tasks” means every task that principal created, with no isolation between individual non-shim clients. The token is owner-readable only and never appears in descriptors, list_agents output, Agent Cards or logs. A principal without agent registration may send inform and ask and read, list or cancel its own tasks; command requires a registered agent whose role/owner satisfies §7. The conformance test configures the sample client with the peer token. The peer token has one creator, the A2A server’s Listen (an exclusive create, idempotent across shims); each agent’s own token is created by the shim’s A2A wiring at ~/.local/state/crosstalk/tokens/<agent_id>.token before Listen, and a receiver accepts a sender’s own token only when its descriptor’s credential_ref is exactly that path for its agent_id.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).
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.
turn/start {threadId,input:[],toolOutput:{name:"receive_message",namespace:"crosstalk",output:<JSON text>,retain:true}}. Idle starts a turn; active regular work consumes the tool output at the next model boundary. No user input, active-turn lookup, expected-turn retry, or model/cwd/approval overrides.next_turn and all unverified senders use thread/inject_items {threadId,items:[{type:"function_call_output",name:"receive_message",namespace:"crosstalk",output:<same JSON text>}]}. This is a no-wake history append. next_turn means the next model request, which can be within an already-active turn, not a strict new-turn boundary. Idle work is never started by this path. queued requires acknowledged append; shim mailbox placement alone is insufficient.activeTurnNotSteerable error from turn/start, recognized by the error message text (case-insensitive) or the structured error.data key (the app-server defines only review and compact today) falls back once to the deferred append. Other definite refusals and pre-send failures are unreachable; no retry. A valid immediate response naming a turn is accepted; the append’s empty result is queued. Malformed success or a possibly sent request without a response is unknown, with no resend or alternate-path fallback.thread/turns/list and thread/items/list; lack of a displayed message is never proof of nondelivery. Reconnect cannot automatically resolve these ambiguous appends, which stay unknown rather than being re-injected.functionCallOutput item, but stock clients may hide generic tool output; raw appends can be omitted from normal history. No sender badge is promised. Never use the internal codex_tui/codex_app namespace to impersonate native delegation.crosstalk capture remains a diagnostic; its deliberately failing steer probes and existing version fixtures remain valid. Production delivery no longer invokes codex queue or turn/steer.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.
crosstalk mcp)Seven tools. An unbound call (§3) is served list_agents only; every other tool returns unbound.
list_agents() → every discovered session with crosstalk, reachable, status, cwd, harness, name (§3).send_message(to, text, kind="inform", delivery="immediate") → {message_id, state}; sent over A2A with returnImmediately: true. Since the far side answers submitted at once, the sending shim polls GetTask for up to 2 s for a delivery state past submitted/dispatching and returns it; if none arrives it returns unknown with reason pending and keeps the far task id on the journal’s outbound record.reply(message_id, text) → {message_id, state}. message_id is the inbound message being answered; the destination comes from the stored original, not the model. In order: journals the reply-delivery obligation (CQ4); completes the ask’s A2A task, if any, with the reply text as its final Message; when the asker is a shim-served agent, sends the reply to the asker’s shim as a new inform with in_reply_to = message_id, which injects it into the asker’s bound session; then acknowledges the caller. The reply has its own message_id, so a retry, a repeated GetTask or a sender reconnect is deduped and never injected twice (C4). An undelivered obligation is retried from the journal after a restart.ask(to, text, deadline_seconds=0) → {message_id, state} at once, where message_id is the ask’s id; no blocking; the answer arrives through reply above. deadline_seconds (V15): when it elapses with no reply, the task becomes failed with metadata.crosstalk.reason: "deadline" and the asker’s session receives an inbound inform with in_reply_to set saying the ask expired; already-injected work is not stopped. 0 = no deadline. The deadline is journaled on the outbound ask record (sent_at + deadline_seconds); replay on registration re-arms the timer of every unanswered ask of the current conversation and expires at once any already past.to in send_message and ask, and the target of claim and release, is an agent name or an agent_id. Resolution: agent_id-shaped values try registered then native agent_id; other values try registered then native name; an empty value matches nothing. The shape of the value picks the lookup: an agent_id-shaped value (16 lowercase hex) matches agent_ids only and never a name, so an agent whose name is 16 lowercase hex is addressable only by its agent_id; any other value matches a registered name without discovery, then a native name; one native match whose session is registered under another name resolves to that registration. Claim of a native id is rejected/no_such_agent.send_message or ask result may carry hint, plain text naming the model’s next step; reason stays the stable code. ask or a command to a session with no CrossTalk binding is rejected/native_inform_only with a hint: when reachable, send_message reaches it and its first CrossTalk call binds it, then ask works; when not reachable, nothing reaches it now. A name that matches several native sessions is rejected/ambiguous_name for every kind, with a hint to address one by agent_id.receive_message(after?, limit?) → mailbox pull (V23, C9). Every accepted message is journaled. The cursor after is a message_id located over the full journal ordering, but the default state filter always applies, with or without after: the tool never returns accepted or queued messages. By default it returns only entries in state unreachable, unknown, recovered or bounced (not accepted or queued, both of which the harness itself delivers), so a model never receives a delivered message twice; it also returns the agent’s own outbound messages whose latest state is unknown (for example a send whose far-side task was still pending when the settle poll timed out), marked "direction":"outbound" with to, task_id and reason, while inbound entries carry "direction":"inbound"; after only moves the starting point in that ordering. Each entry carries direction, message_id, task_id, from, state, shim instance_id and bound session_ref; credentials redacted. An inbound entry that carries text also carries notice, the receiving harness’s own peer-text notice (harness.Noticer: claude.Notice or codex.Preamble), so a pulled message is framed like a delivered one.claim(name) → {name, owner}: the caller, a registered primary, becomes the owner of the unclaimed registered agent name (§7). Across shims the claim is an A2A SendMessage carrying metadata.crosstalk.claim: {owner, claimed} with the owner’s own token; it creates no task, never reaches the receiver chain, and is refused unless owner is the authenticated sender.release(name) → {name}: the owner ends its claim on name (§7).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.
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:
claude mcp add --scope user crosstalk -- <abs path>/crosstalk mcp~/.codex/config.toml, [mcp_servers.crosstalk] with command = "<abs path>/crosstalk", args = ["mcp"] and default_tools_approval_mode = "approve" (stops Codex prompting on every CrossTalk tool call)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).
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.
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.userMessage item is created.queued), idle remains idle, and a later independently started turn consumes the data.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.turn/started.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.
unbound.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.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.
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.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.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).dispatching record is reconciled only with positive evidence, otherwise stays unknown (including raw Codex appends absent from client history), and is never re-injected.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.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.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).rejected before acceptance; every previously accepted message is still accounted for in the journal and receive_message.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.tools/call by _meta.threadId and never cross-binds; a call with no _meta.threadId is served list_agents only.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.
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.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.ambiguous_endpoint, no binding./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.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._meta.threadId / _meta.sessionId on tools/call is release-source evidence (rust-v0.166.0 codex-rs/core/src/mcp_tool_call.rs), not a documented API: captured live by spike 9 before the Codex adapter, verified live on 0.166.0 (docs/spikes/09-codex-binding/): a model-issued tools/call carried _meta.threadId and _meta.sessionId; mcpServer/tool/call {threadId} reached the per-thread instance before first use and during an active turn, without experimentalApi. Re-verify per Codex release.accepted; an ack line decides the state if one is ever sent; EOF, reset or any other reply is unknown. This is a weaker operational definition than a Claude acknowledgement and is delivery evidence only. Our own uds ingress replies {"type":"ack","message_id","state","reason"} on the authenticated shape (a).<uds_path minus .sock>.key, 0600, created on first start. Resolved (spike 10, ADR 0009): a native Claude SendMessage to a uds: address writes the verified single-line native frame without the key; the shim journals it bounced and injects a bounce (test 11), so Claude never needs the key, which authenticates only CrossTalk’s own shape (a) clients.Access-Control-* header and Vary is stripped, OPTIONS answers 204, a non-loopback Host gets 421; residual name, role and harness exposure to same-machine browsers is accepted.inform only; ask or command to it is rejected/native_inform_only; to a target that resolves to nothing it is rejected/no_such_agent (no deadline timer, no expiry notice, no mailbox entry).InformRouteTTL (1 h) on the journal clock and are swept on admission; the bounce lists every open ask then the ten newest informs with a count.docs/plans/agent-plugins-clean-run.md).NativeIngress implementation for all agents. A second native ingress protocol, a deferred-delivery (mailbox) capability and installation hooks are outside the current harness.Harness interface, so “one package plus one line” holds only within the supported extension points. Render is exposed on the interface, but each injector builds its own payload; it is not the canonical path of the shared dispatch. Revisit when a second native protocol is chosen.SessionStart fires on startup, resume, clear, compact and fork, not once per process. A hook must inform the existing shim, never start one; it must be idempotent per (thread id, agent instance): a fork gets its own binding and never inherits the parent’s, and the hook’s session_id → app-server thread id mapping is verified first. SessionStart may fire before the model-tool connection is ready; SessionEnd may lag pane closure.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.
codex queue CLI as v1 next_turn, RPC pair noted; F4 transport-peer vs original-sender, native reply routing; F5 credential_ref, redacted discovery; F6 native-message normalization, atomic dedupe, uncorrelated state; F7 unauthorized command rejected not downgraded, unknown kind rejected, nested envelope rule; F8 instance_id, bind-then-publish, own-instance cleanup; F9 Codex hook idempotency; F10 failure-branch tests. Tests 12–16 added. States gained uncorrelated (reply mailbox only).--remote pinning (test 18); queue RPC availability stated precisely.LOCAL_PEERPID peer pid (§5, ADR 0002); V2 harness_pid + harness_started_at in the descriptor, identity from descriptors only (§3, §5); V3 kind is a hint, not a trust boundary (§7, ADR 0004, test 23); V4 uds auth token and meaning of unverified (§5); V5/C4 ask as third kind, reply completes the task and delivers into the asker’s session (§4, §5, §7, §8); V6/C8/CQ2 completion on evidenced delivery, completion: "delivered" (§5, §6, §8); V7 idle-Claude gate (§6, test 19); V8/C6/CQ4 per-agent journal, recovery rules, conversation-bound records (§5, §9, tests 20, 21); V9/C2 single launcher crosstalk run + crosstalk mcp (§9, §12, test 26); V10 spikes index docs/spikes/README.md, inline definitions of F/R2/spike references (Terms, ADRs 0001, 0003); V11 harness_version check and crosstalk capture (§3, §6); V12/CQ3 unverified senders never turn/start, stated as scheduling only (§6, §7, tests 3, 9, 19); V14 own-token verification (§5); V15 deadline_seconds (§8); V16 state definitions (§8, test 6); V17 §10 rewrite; V18 pid-reuse check at accept() (§5); V19 adapter.Fake (§2, test 16); V20 test 5; V21 test 11 as regression for spike 7 (§3, §11); V22 a2a-go/v2 pin (§5); V23 mailbox contract (§8); V24 §3 sentence fix; V25 Terms; V26 delivery no-op on Claude (§4, §6); V27 preamble and escaping (§6); V28 changelog contiguous, review disposition moved; V29 cwd out of the card description (§5); C1 conformance matrix, version, securityRequirements, ListTasks, SendMessageResponse, terminal-continuation error (§5, test 17); C3 continuation authorization, typed command encoding, ask without command authority (§4, §5, §7, test 23); C5 dispatch claim before harness write (§5, test 22); C7 per-name flock (§3, test 24); C9 admission limits (§5, §8, test 25); CQ1 default SendMessage waits for terminal or interrupted only (§5, test 17). V13 superseded by C5. Removed: turn-boundary task completion (V6); default SendMessage returning at working and the server-timeout return (CQ1); task-store compare-and-set (C5); request_id and “reply is delivered later as an ordinary inbound message” (V5); “it does not merely post a new message to the asker” (V5); “Verified today” on the Claude idle path (V7); terminal (send-keys) fallback, SessionStart hooks as v1 launch, “unless R2 says yes”, 0.x method names (V17); cwd from the Agent Card description (V29); “uds frame without a verified command contract is inform regardless” (C3); the §12 launch-location question (V9). Tests 3, 5, 6, 9, 11, 16, 17 rewritten; tests 19–26 added.uds_path is name-stable under ~/.local/state/crosstalk/sock/, the Claude-visible pair becomes a symlink (§3); launch order binds sockets before spawning, per-session MCP configuration carries the forwarder socket (no environment inheritance), not_ready/unbound/shim unavailable outcomes, fork fails closed, shim-crash adoption of a surviving harness child (§9); tests 21 and 26 rewritten as real-process tests; spike 8 extended to the MCP launch path.crosstalk run wrapper; stateless crosstalk mcp forwarder and its private instance socket; per-session MCP configuration and the per-session socket variable; harness-child adoption after shim crash; launch-time --name/--role/--owner; herdr_pane descriptor field; shim unavailable outcome; spike 8; the §12 wrapper questions and the v1.1 MCP-as-shim candidate. Added: crosstalk mcp is the shim, installed once per harness (§9); per-request identity (Claude parent pid → ~/.claude/sessions/<ppid>.json; Codex params._meta.threadId/sessionId), unbound serves list_agents only, one agent record per (harness, session_ref) with Codex connection pooling (§3); names from the harness session name with fallback and rename re-registration (§3); Codex endpoint found by thread/read (§3); installed descriptor field; discovery of every session with crosstalk and reachable flags (§3, §8); claim/release and default role primary (§7, §8); lazy per-agent state and plain exit semantics (§9); tests 26 rewritten, 27–28 added; spike 9. Also removed: the Claude-visible ~/.claude/sessions pair and spike 7 (reply addresses travel in the envelope). Added: name-collision fallback rule, ambiguous_endpoint, shim-to-shim over A2A only, tests 10/11 reworded. Spike 9 completed live by the Codex session (docs/spikes/09-codex-binding/); per-agent listeners in a shared process specified (§9); pooled-pid attribution rule (§5).agent_id (sha256 of harness:session_ref) keys all state, sockets, locks, claims and routing; the harness name is an alias with its own lock; fallback names use the full-id hash with explicit collision extension; rename changes the alias only; Codex endpoint binding requires thread/loaded/list membership and fails closed on two hosts; _meta.threadId required, sessionId optional; stale Claude-visible pair sentence deleted; tests 29–31.protocol bump suffices); V33 expired task state (not in A2A 1.0); V34 skills mirrored into descriptors (one possible value); V35 unverified senders mailbox-only (silently loses test 9’s legitimate case); V36 A2A ingress-only without tasks (operator ruled tasks in; ask needs a task); V37 a2a-go helloworld precondition (resolved by the V22 pin step).in_reply_to hint, never completing an ask or closing a route, after live round 4 showed a native ack of an inform completing an overlapping ask with the wrong text.uncorrelated replaced by bounced; receiver Forwarder removed.claim/release; command and ask are A2A-only), ADR 0008 (extension-point limits stated in the decision). Other ADRs corrected against the code (0002 platform files, 0006 unbound behaviour, 0009 bounce cap, 0010 test note); all Accepted statuses set to Provisional. Header range now ADR 0001–0011./clear): optional harness.Supersession, readers skip, holder retires, names retried on every bound call (§2, §3, §9, ADR 0003, ADR 0008, test 32); hint on send results, ambiguous names are ambiguous_name for every kind (§8, test 33).