Proxy
proxy
¶
ACP proxy — one connection's worth of typed JSON-RPC mediation.
ACPProxy is the bridge behind
ACPRoster.attach. It
implements both sides of the ACP protocol on the same object:
acp.Agent— facing the connected ACP client (Zed, Toad, …). Anacp.agent.connection.AgentSideConnectionreads the client's frames, deserialises them into typed pydantic models, and dispatches toself.initialize/self.new_session/self.prompt/ etc.acp.Client— facing the bound in-container backend wrapper. Once a model has been picked, aacp.client.connection.ClientSideConnectiontoterok-{agent}-acpreads the wrapper's frames and dispatches backend → client traffic (session/update,request_permission,fs/*,terminal/*) onto the same proxy object so it can forward to the connected client.
Two phases drive the lifecycle:
- Pre-bind:
initializeandsession/newanswer locally, advertising the aggregatedagent:modellist as aconfigOptions[category=model]selector. No backend process exists yet. - Bound: on the first model-picking client request —
session/set_config_option(category=model), or lazily on the first backend-needing method likesession/prompt— the proxy spawns the in-container wrapper throughacp.spawn_agent_process, replaysinitialize+session/new+session/set_config_optionagainst it, and from then on forwards typed calls in both directions. Backend responses and notifications carrying model ids are re-namespaced on the way out so the client always seesagent:modelids.
ACP 1.16 (SDK 0.11) retired the dedicated model channel —
session/set_model, SessionModelState, ModelInfo are all gone —
and model selection now rides the generic config-option mechanism that
older Zed already used. That collapsed two code paths into one: the
proxy speaks config options to the client and to the backend.
V1 takes shortcuts where the design is still settling: one session per connection (Zed reconnects on every chat — fix on the roadmap), one bound agent per session (no cross-agent switches without reconnect), no push notifications when the authed-agent set changes mid-connection.
CLIENT_SESSION_ID = 'proxy-1'
module-attribute
¶
Synthetic session id the proxy advertises to the connected client.
Backend session ids never reach the client — every backend-originated
frame has its sessionId rewritten to this constant on the way out.
CONTAINER_WORKSPACE = '/workspace'
module-attribute
¶
Path the backend session/new runs in.
ACP clients send their host filesystem path in new_session.cwd
(Zed: /var/home/user/prog/X) which doesn't exist inside the
container. claude-agent-acp chdirs into cwd before exec; an
ENOENT there surfaces as the famously misleading "Claude Code native
binary not found …". Pinning to the container's workspace mount is a
stopgap until the host↔sandbox path strategy lands.
PROXY_AGENT_NAME = 'terok-acp'
module-attribute
¶
PROXY_AGENT_TITLE = 'Terok ACP host-proxy'
module-attribute
¶
PROXY_AGENT_VERSION = '1'
module-attribute
¶
SessionUpdatePayload = UserMessageChunk | AgentMessageChunk | AgentThoughtChunk | ToolCallStart | ToolCallProgress | AgentPlanUpdate | AgentPlanContentUpdate | AgentPlanRemovedUpdate | AvailableCommandsUpdate | CurrentModeUpdate | ConfigOptionUpdate | SessionInfoUpdate | UsageUpdate
module-attribute
¶
McpServerConfig = HttpMcpServer | SseMcpServer | AcpMcpServer | McpServerStdio
module-attribute
¶
Every MCP server shape session/new and friends accept.
Spelled once because the proxy forwards the client's mcpServers list
verbatim through four session-opening methods, and the union must stay
identical to the SDK's — list is invariant, so a member missing here
(AcpMcpServer was, before ACP 1.16 added it) makes every forward a
type error rather than silently dropping a server.
ACPProxy(*, roster)
¶
One client connection's worth of proxy state.
Constructed by attach;
lives for the duration of a single client connection. Not reusable
— discard after run returns.
Source code in src/terok_executor/acp/proxy.py
run(reader, writer)
async
¶
Run the typed proxy loop until the client disconnects.
Hands the client side to
acp.agent.connection.AgentSideConnection
which dispatches typed methods on this object. Always tears the
bound backend down on exit, even on cancellation.
Source code in src/terok_executor/acp/proxy.py
on_connect(_conn)
¶
initialize(protocol_version, client_capabilities=None, client_info=None, **_kw)
async
¶
Answer initialize locally and capture the client's caps.
client_capabilities is replayed verbatim on the backend's
initialize during bind — whatever the client said it can do,
the backend believes. client_info is accepted to satisfy
the protocol but discarded (the proxy doesn't relay it).
Source code in src/terok_executor/acp/proxy.py
new_session(cwd, additional_directories=None, mcp_servers=None, **_kw)
async
¶
Answer session/new with the aggregated model list.
Synthesises CLIENT_SESSION_ID
so the client can proceed to model selection before any backend
exists. The real backend session id is captured on bind and
translated on every forwarded frame.
Source code in src/terok_executor/acp/proxy.py
set_config_option(config_id, session_id, value, **_kw)
async
¶
Bind on category=model; otherwise forward to the bound backend.
This is now the only way a client picks a model: ACP 1.16
retired the dedicated session/set_model request, folding model
selection into the generic config-option channel that older Zed
already used. Non-model options pass through to the bound
backend; a non-model option pre-bind is rejected.
Source code in src/terok_executor/acp/proxy.py
prompt(session_id, prompt, **_kw)
async
¶
Lazy-bind to the default model if needed, then forward.
ACP 1.16 dropped message_id from PromptRequest — message
ids now ride on the streamed content chunks instead — so there is
nothing left to relay but the prompt itself.
Source code in src/terok_executor/acp/proxy.py
cancel(session_id, **_kw)
async
¶
Fire-and-forget cancel to the backend (no-op if not bound).
Source code in src/terok_executor/acp/proxy.py
authenticate(method_id, **_kw)
async
¶
Forward to bound backend; pre-bind authenticate is rejected.
Source code in src/terok_executor/acp/proxy.py
set_session_mode(session_id, mode_id, **_kw)
async
¶
Forward to bound backend.
Source code in src/terok_executor/acp/proxy.py
load_session(cwd, session_id, mcp_servers=None, additional_directories=None, **_kw)
async
¶
Forward to bound backend (v1 advertises no session-load capability).
Source code in src/terok_executor/acp/proxy.py
list_sessions(cwd=None, cursor=None, **_kw)
async
¶
Forward to bound backend.
ListSessionsRequest lost additionalDirectories in ACP
1.16 — listing is scoped by cwd alone now.
Source code in src/terok_executor/acp/proxy.py
fork_session(session_id, cwd, additional_directories=None, mcp_servers=None, **_kw)
async
¶
Forward to bound backend.
Source code in src/terok_executor/acp/proxy.py
resume_session(session_id, cwd, additional_directories=None, mcp_servers=None, **_kw)
async
¶
Forward to bound backend.
Source code in src/terok_executor/acp/proxy.py
close_session(session_id, **_kw)
async
¶
Forward to bound backend and tear down the wrapper.
v1 keeps one backend per connection; after a successful close the
wrapper has nothing more to do, so reap it eagerly instead of
leaving it around to be killed by _teardown_backend on
disconnect. Returns None when no backend was ever bound.
Source code in src/terok_executor/acp/proxy.py
ext_method(method, params)
async
¶
Forward extension methods to the bound backend.
ext_notification(method, params)
async
¶
Forward extension notifications to the bound backend (silent if not bound).
Source code in src/terok_executor/acp/proxy.py
session_update(session_id, update, **_kw)
async
¶
Rewrite session id and any model ids, then forward to the client.
Source code in src/terok_executor/acp/proxy.py
request_permission(session_id, tool_call, options, **_kw)
async
¶
Forward permission request to the connected client.
Source code in src/terok_executor/acp/proxy.py
read_text_file(session_id, path, line=None, limit=None, **_kw)
async
¶
Forward fs read to the connected client.
Source code in src/terok_executor/acp/proxy.py
write_text_file(session_id, path, content, **_kw)
async
¶
Forward fs write to the connected client.
Source code in src/terok_executor/acp/proxy.py
create_terminal(session_id, command, args=None, env=None, cwd=None, output_byte_limit=None, **_kw)
async
¶
Forward terminal create to the connected client.
Source code in src/terok_executor/acp/proxy.py
terminal_output(session_id, terminal_id, **_kw)
async
¶
Forward terminal output read to the connected client.
Source code in src/terok_executor/acp/proxy.py
release_terminal(session_id, terminal_id, **_kw)
async
¶
Forward terminal release to the connected client.
Source code in src/terok_executor/acp/proxy.py
wait_for_terminal_exit(session_id, terminal_id, **_kw)
async
¶
Forward wait-for-exit to the connected client.
Source code in src/terok_executor/acp/proxy.py
kill_terminal(session_id, terminal_id, **_kw)
async
¶
Forward kill to the connected client.
Source code in src/terok_executor/acp/proxy.py
create_elicitation(message, mode, **_kw)
async
¶
Forward an elicitation request to the connected client.
The backend addresses the elicitation to its session; the client
only knows CLIENT_SESSION_ID,
so session-scoped modes get the same id rewrite every other
backend → client frame gets. Request-scoped modes carry no
session and pass through untouched.
Source code in src/terok_executor/acp/proxy.py
complete_elicitation(elicitation_id, **_kw)
async
¶
Forward the completion of an out-of-band elicitation to the client.
AgentBindError
¶
Bases: RuntimeError
Surface error raised when the proxy fails to bind a backend agent.
Always converted to a JSON-RPC error response on the wire — never
bubbles to the caller of run.