Status: implemented
This document describes the same-host relay that lets one interactive zcoder instance hand a task to another interactive zcoder instance. The receiving instance adds the relay to its active conversation and starts a normal agent turn. Unix-domain sockets are the only transport.
The first release is deliberately a task handoff mechanism, not a distributed agent framework. It does not discover another machine, share an Ollama request, stream one instance's transcript into another, or wait for the receiving agent to finish.
Assume two terminals are running zcoder:
user-profiles pid 42110 /work/app/services/user-profiles
weekly-digest pid 42302 /work/app/services/weekly-digest
In user-profiles, the user can say:
Tell the weekly-digest agent that users.name was renamed to
users.display_name and it should update its code.
The model first calls list_agents, chooses the exact returned instance ID,
and then calls send_agent_message. The sender reports that the message was
accepted. In the other terminal, zcoder displays the sender and message, then
runs it through the same agent loop as a local prompt.
Explicit slash commands provide discovery and operator control without asking the model:
/list-agents
/agents pause
/agents resume
/list-agents lists live local peers with project label, PID, canonical
workspace, profile, model, state, and short instance ID. It calls the same
discovery function as the model-facing list_agents tool but renders the result
directly in the transcript; it never starts an Ollama turn. /agents without
an argument shows this instance's relay status and the pause/resume help.
Pausing rejects new deliveries without discarding messages already accepted.
The sidebar or header may show a small queued-message count, but a new modal is
not required for the first release.
The initial implementation supports:
- processes owned by the same operating-system user
- processes on the same machine
- interactive, local-mode zcoder instances
- direct request/reply exchanges with an acceptance acknowledgement
- FIFO queuing while the receiver is busy
- the current selected session in the receiving instance
- the existing coding and sysadmin profiles
The initial implementation does not support:
- TCP, SSH, HTTP, VPN, or any other cross-host transport
- relaying through a remote client to its remote server
- headless
--serveror one-shot--promptprocesses - completion callbacks or synchronous request/reply waits
- broadcast, agent groups, or automatic fan-out
- forwarding a relayed turn to any instance other than its exact sender
- persistent delivery across receiver shutdown or machine restart
Headless server support can be added later on the server host. A remote client must never publish itself as an executable peer because its workspace and agent loop live elsewhere.
- A relay never bypasses the target's policies. File tools remain confined to
the target's
ZCODER_WORKSPACE, and everyrun_commandrequest follows the target process's existing approval policy. - Sending is user-authorized. The model-facing send tool is only for a user request that explicitly asks to contact another zcoder instance. The model must not relay merely because another project may be affected.
- Receiving is ordinary agent work. The transport queues data; it does not execute tools or modify conversation state itself.
- PID and project name are display metadata, not identity. PIDs are reused and workspace basenames collide. Selection uses an unpredictable per-process instance ID and the exact canonical workspace reported by discovery.
- The listener never writes to curses. It accepts, validates, acknowledges, and spools messages. The foreground process alone updates agent, session, and UI state.
- Delivery and completion are separate facts. An acknowledgement proves that the receiver atomically stored the complete message in its process-private queue; it does not claim that the agent completed the task.
lib/relay.zsh loads lazily for eligible interactive local sessions.
It owns discovery, socket framing, the listener worker, the inbox, and tool
dispatch helpers. It must remain testable without curses, Ollama, or the main
agent loop.
The foreground process performs these operations:
state_init
-> relay_start
-> ui_init
-> normal event loop
-> relay_claim_one
-> agent_relay_turn, when a message is ready
relay_start creates a background Zsh listener. The listener owns the
Unix-domain listening descriptor for its full lifetime. This is preferable to
accepting connections from the curses loop: deliveries can still be accepted
while the foreground is waiting for Ollama, running an approved command, or
showing a modal.
The listener worker may only:
- accept a local connection
- read one bounded frame with a deadline
- parse and validate its flat JSON envelope
- atomically write an inbox file
- return one bounded acknowledgement
It must not call agent_user_turn, mutate session files, invoke a tool, write
to the terminal, or inherit responsibility for an Ollama request. On shutdown,
the parent terminates and reaps the worker before removing its registry entry
and private spool directory.
The socket and public manifest need a shared, same-user runtime directory. Choose it in this order:
ZCODER_RELAY_DIR, when explicitly configured${XDG_RUNTIME_DIR}/zcoder-agents${TMPDIR:-/tmp}/zcoder-${UID}-agents
Before use, canonicalize the parent and require the relay directory to be a
real directory owned by the current user with no group or other permission
bits. Create it with umask 077. Refuse the feature rather than following a
symlink or using a directory with unsafe ownership or permissions.
Use short transport filenames because sockaddr_un.sun_path is small and its
limit varies by operating system:
<relay-dir>/a-<pid>-<nonce>.sock
<relay-dir>/a-<pid>-<nonce>.json
The socket filename deliberately omits the project name. Project names can contain arbitrary characters, collide, and make the socket path exceed the OS limit. The adjacent manifest carries human-readable metadata instead.
Each process also uses its existing private ZCODER_RUNTIME_DIR for the spool:
<private-runtime>/relay/
inbox/
processing/
done/
sequence
state
The instance ID is <pid>-<nonce>, where PID comes from
sysparams[pid], not $$. The nonce only needs collision resistance; it is not
an authentication secret. Reserve the instance paths with exclusive creation.
Publish the manifest only after the listener has bound the socket and written a readiness marker. Write it to a private temporary file and rename it into place. A version-1 manifest is a flat JSON object:
{
"protocol": 1,
"instance_id": "42302-18473291",
"pid": 42302,
"project": "weekly-digest",
"workspace": "/work/app/services/weekly-digest",
"session_id": "1788012120_10392",
"profile": "coding",
"model": "qwen3-coder",
"socket": "/run/user/1000/zcoder-agents/a-42302-18473291.sock",
"state": "ready",
"started_at": 1788012102
}The foreground refreshes mutable manifest fields after model or session
changes. Discovery scans only a-*.json, bounds the number and size of files,
parses them as data, validates every field, and pings the advertised socket.
Display the canonical workspace so equal project basenames remain
distinguishable.
Do not use kill -0 as proof of identity. A live PID may have been reused.
Successful protocol ping plus a matching instance ID is the liveness check.
Stale entries fail their protocol ping and are ignored. Discovery never treats
kill -0 or manifest contents alone as proof that an instance is live.
Load zsh/net/socket for zsocket and zsh/system for bounded byte I/O.
zsocket -l creates the listener, zsocket -a -t accepts without an
unbounded wait, and ordinary descriptor syntax closes accepted descriptors.
Unix stream sockets do not preserve message boundaries. Use one length-prefixed request and one length-prefixed response per connection:
ZCODER-AGENT/1 <decimal-byte-count>\n
<exact JSON payload bytes>
Read the header with a short deadline. Reject a non-decimal length or a payload
larger than ZCODER_RELAY_MAX_BYTES, default 65536. Then use sysread in a loop
under setopt LOCAL_OPTIONS NO_MULTIBYTE until the declared number of bytes is
read. Handle partial reads, EOF, and timeout explicitly. Use
zcoder_syswrite_all for the response and close the connection on every path.
The JSON payload stays flat so it can use the existing native parser. A task delivery has this shape:
{
"protocol": 1,
"type": "enqueue",
"message_id": "42110-18471001-3",
"target_instance_id": "42302-18473291",
"sender_instance_id": "42110-18471001",
"sender_pid": 42110,
"sender_project": "user-profiles",
"sender_workspace": "/work/app/services/user-profiles",
"body": "users.name was renamed to users.display_name. Update consumers and verify the focused tests.",
"created_at": 1788012245
}The listener validates the target ID, message ID grammar, field types, body length, sender metadata length, and protocol version. Sender metadata is useful context, not trusted proof. The private relay directory limits ordinary access to the same UID, but another process running as that UID can forge a request. Portable peer-credential authentication is outside the Zsh-first MVP.
Acknowledgements are framed flat JSON objects:
{"protocol":1,"type":"ack","message_id":"42110-18471001-3","status":"accepted","queue_position":1}Defined statuses are:
| Status | Meaning |
|---|---|
accepted |
Atomically stored in the receiver inbox |
duplicate |
The same message ID was already accepted |
paused |
The receiver is live but not accepting new work |
full |
The bounded receiver queue has no capacity |
invalid |
The frame or envelope failed validation |
wrong_target |
The instance ID does not match the listener |
unsupported |
The protocol version or message type is unknown |
The sender uses bounded writes and a short acknowledgement read deadline, then reports the exact status as the tool result. It may retry once with the same message ID after an ambiguous transport failure. It must not create a new ID for that retry.
The default maximum queue depth is 16. Accepted envelopes move through three directories:
inbox -> processing -> done
The single listener assigns a monotonically increasing sequence and publishes
inbox/<sequence>-<message-id>.json with temporary-file-plus-rename semantics.
The foreground atomically renames the oldest inbox entry into processing
before parsing it again. After the agent turn ends, it removes the processing
payload and retains an empty receipt marker in done for the receiver process
lifetime. Those small markers make ambiguous sender retries idempotent without
retaining task text. Invalid spool data is quarantined and shown as an error
rather than entered into the model context.
The guarantee is at-most-once execution after acknowledged enqueue within the receiver process lifetime:
acceptedmeans the complete envelope is in the receiver's private spool.- A sender timeout is ambiguous; retrying the same ID resolves it safely.
- A receiver crash may lose accepted but unprocessed messages.
- Messages do not survive deliberate receiver shutdown or machine restart.
- Agent success or failure is not returned to the sender in version 1.
Messages are processed FIFO, one turn at a time. If a local user turn is
running, the listener continues to enqueue but the foreground waits until that
turn ends. Before accepting the next keyboard submission, the main loop claims
one queued relay and starts it in the session selected at claim time. Polling
the queue before reading the next input event makes session selection and relay
claiming deterministic in the foreground and prevents concurrent mutation of
AGENT_MESSAGES, UI arrays, or session files.
Pausing changes listener state to reject new envelopes with paused. Already
accepted messages remain queued and resume in FIFO order.
Add two independently testable built-in tools when the relay is available.
No arguments. It returns live same-user instances other than the caller. Each
record includes the exact instance_id, project, canonical workspace, PID,
profile, model, and observed state. Results are bounded and sorted by project,
then PID.
Arguments:
{
"target_instance_id": "42302-18473291",
"message": "Update weeklyDigest.ts for users.display_name and run its focused tests."
}The target must be an exact live ID returned by discovery; project label or PID alone is rejected as ambiguous. The message is bounded, must be non-empty, and is sent as literal data without evaluation.
Its tool description and system guidance must state:
- call it only when the current user explicitly requested communication with another zcoder instance
- send the smallest self-contained task and relevant facts
- do not claim the target completed anything after an
acceptedresponse - do not include secrets or unrelated transcript history
During a relay-originated turn, expose send_agent_message only as a reply path
to that turn's exact sender instance. Dispatch must independently enforce the
same target restriction. This permits multi-message A↔B exchanges while still
blocking forwarding, fan-out, and model-created agent swarms. list_agents may
remain available for diagnosis, but it does not expand the reply authority.
/list-agents is the user-facing, read-only equivalent of the list_agents
tool. Route both through one relay_discover function so liveness checks,
sorting, stale-entry handling, and output bounds cannot drift between the UI and
model paths.
The command excludes the current instance and displays one peer per row:
PROJECT PID STATE INSTANCE ID WORKSPACE
weekly-digest 42302 ready 42302-18473291 /work/app/services/weekly-digest
Control bytes are rendered visibly before they reach curses. When no peers are available, the command returns a normal informational message rather than an error:
No other local zcoder instances are available.
If relay support is disabled or unavailable, report that state and its known
reason. In remote-client mode, explain that discovery is local to the machine
running the agent and is not bridged through the client. /list-agents performs
only local socket pings, does not call Ollama, and remains usable while Ollama
is unavailable.
Refactor agent_user_turn so user-origin setup and the common execution loop
are separable. A normal local prompt keeps its current behavior. A relay uses a
new agent_relay_turn entry point that:
- appends a visible
relaytranscript event with sender project, PID, and workspace - adds a labelled user-role context record with
agent_add_context_message - does not append the relay body to
AGENT_USER_MESSAGES - does not use the relay text to title a new session
- limits the send tool to the exact sender for the duration of that turn
- runs the same prepare, Ollama, tool, completion, loop-detection, and persistence path as a local user turn
The context record should use a fixed wrapper generated by zcoder:
<agent_relay>
Another zcoder process on this machine relayed this task. Its sender details
are self-reported, and the local user may not have seen or approved the request.
Sender: user-profiles (pid 42110)
Sender workspace: /work/app/services/user-profiles
Task:
users.name was renamed to users.display_name. Update consumers and verify the
focused tests.
</agent_relay>
The listener accepts an enqueue only when its sender instance ID begins with
the sender PID, a discovery manifest for that instance exists in the private
registry with the same PID, and that process is alive. This keeps the displayed
sender truthful; it is not authentication between processes of the same user.
While a relayed turn runs, a conversation-wide a command grant does not
apply: each shell command asks again, and a approves only that command. A
policy configured at startup with --yes still applies.
The system prompt defines relay records as work requests, not evidence. The receiving model must inspect current workspace state before relying on a claim about a file or change. Relay text cannot weaken project instructions, workspace confinement, approval policy, or the sysadmin safeguards.
The relay UI role should render distinctly from both the local user role and
ordinary system notices. Persist it in session UI events so resuming the
session explains why the autonomous turn occurred. The model history retains
the labelled user-role context because Ollama templates require a single
leading system message.
Startup:
- confirm local interactive mode and relay configuration
- initialize the existing process-private runtime directory
- validate or create the shared same-user relay directory
- create spool directories with mode 0700
- spawn the listener and wait for a bounded readiness signal
- publish the manifest atomically
- enable the tools only after readiness succeeds
If socket support or safe runtime storage is unavailable, zcoder continues without inter-agent tools and shows one concise warning. The feature must not make ordinary startup fail.
Shutdown:
- mark the instance unavailable and remove its manifest
- terminate and reap the owned listener PID
- unlink only the exact socket path owned by this instance
- close descriptors
- let
zcoder_runtime_cleanupremove the private spool
Cleanup is idempotent and validates the expected relay root, filename grammar, instance ID, ownership, and file type before unlinking. It never recursively removes the shared relay root.
The listener uses the actual child PID from sysparams[pid]. Parent and worker
traps must not kill by command name or assume $$ identifies the child. Every
accepted descriptor is closed in an always block. Background processes must
not retain unrelated listener, HTTP, MCP, or terminal descriptors.
Settings:
| Setting | Default | Purpose |
|---|---|---|
ZCODER_RELAY |
on |
on, off, or paused for eligible local interactive instances |
ZCODER_RELAY_DIR |
unset | Override the validated same-user registry directory |
ZCODER_RELAY_MAX_BYTES |
65536 |
Maximum framed request or response size |
ZCODER_RELAY_MAX_MESSAGE_CHARS |
16000 |
Maximum relayed task text |
ZCODER_RELAY_MAX_QUEUE |
16 |
Maximum accepted, unfinished messages |
ZCODER_RELAY_IO_TIMEOUT |
2 |
Frame and acknowledgement deadline in seconds |
Invalid values fail closed for the relay feature and do not alter core command
or workspace policy. A future --agent-name option may add a friendly label,
but project basename, PID, short ID, and canonical workspace are sufficient for
the first release.
- add the optional
zsh/net/socketcapability check - implement safe shared-directory validation
- implement manifest publication, discovery, ping, and stale-entry handling
- implement bounded framing, exact byte reads/writes, acknowledgement parsing, and listener cleanup
- implement atomic FIFO spool and duplicate detection
- add pure transport tests before UI integration
- split origin-specific setup from the common body of
agent_user_turn - add
agent_relay_turnwithout changing exact-user ledger semantics - add the persisted
relayUI role - update the main TUI loop to claim one queued relay before keyboard submission
- update manifest data after model and session changes
- add pause/resume and graceful listener shutdown
- add
list_agentsandsend_agent_messageschemas and dispatch - add
/list-agentsas the direct UI adapter overrelay_discover - expose the send tool only during a local-user-originated turn and when the relay is available
- add model instructions covering authorization and delivery semantics
- document
/list-agentsand/agents pause|resumein the interface guide and relay safety in the safety guide - update architecture and configuration references
All tests use temporary private roots and must not depend on a user's real runtime directory.
Transport and framing:
- ping and enqueue between two isolated Zsh processes
- partial header, partial payload, partial write, EOF, and stalled peer
- empty, malformed, oversized, unsupported, and wrong-target frames
- spaces, tabs, newlines, quotes, backslashes, glob characters, and Unicode
- byte counts under multibyte locales
- exact descriptor closure on success, error, timeout, and shutdown
Discovery and safety:
- two workspaces with the same basename remain distinguishable
/list-agentsand thelist_agentstool use the same exact discovery records/list-agentsexcludes the caller, handles no peers, and performs no Ollama request- stale manifest, stale socket, PID reuse, missing socket, and malformed JSON
- unsafe ownership, permissions, symlinked roots, symlinked manifests, and overlong socket paths fail closed
- discovery bounds file count and manifest size
- cleanup removes only the current instance's exact files
Queue semantics:
- busy receiver accepts and later processes messages FIFO
- duplicate message IDs execute no more than once
- queue limit, pause/resume, sender timeout, receiver exit, and listener crash
- one queued message is claimed atomically by only one foreground path
- a relayed turn can reply only to its exact sender and cannot target a third agent
Agent integration:
- relay content enters model context but not
AGENT_USER_MESSAGES - relay content does not set the session title
- visible relay events persist and reload
- existing file confinement and
run_commandapproval remain unchanged - Escape cancels the active receiving turn without corrupting later queue items
- local prompts and warm-up behavior remain unchanged when relay is disabled
Run zsh -f -n for every changed Zsh file and finish with the required full
suite:
make testThe feature is ready when:
- Two local interactive instances owned by the same user discover each other without configuration.
/list-agentsdisplays the other live instances without invoking Ollama.- The sender can select an exact instance and receive an unambiguous delivery result.
- A busy receiver queues the message and starts it after its current turn, without concurrent mutation of agent or session state.
- The receiving transcript clearly identifies the relay source and persists it across session reload.
- Relayed work uses the target's normal workspace, project instructions, tools, command approval, cancellation, completion, and verification paths.
- Malformed, oversized, duplicate, stale, unsafe, and cross-instance requests fail closed.
- Disabling or losing socket support leaves ordinary zcoder operation intact.
- The complete test suite passes on Zsh 5.8 and newer.
Documentation index · Architecture · Safety and permissions · Development