Ergonomic DX layer for agent handler code running inside session/prompt
turn tasks (the functions a turn_runner closure or one of its subagent
tasks actually calls). This module introduces no new runtime semantics:
every function here is a thin, documented wrapper over
Raxol.AgentClientProtocol.Connection.request/4 (the agent->client
filesystem/terminal/permission requests) or
Raxol.AgentClientProtocol.Session.post_update/2 /
Session.request_permission/2 (the Session-owned turn primitives).
Why this isn't built on Connection.Ctx
Raxol.AgentClientProtocol.Connection.Ctx is the per-DISPATCH
struct handed to the request/notification handler callback that answers
session/prompt itself (it carries conn, reply_ref, rx_seq, etc.).
It does not reach the turn task the handler spawns — turn_runner is
injected at Session start with a fixed (session_pid, req) -> ...
arity-2 shape (session.ex moduledoc, "Turn runner seam"), and a
subagent's task closure only ever receives what its parent closed over.
There is deliberately one ctx concept in this package
(Connection.Ctx); this module does not introduce a second,
competing one. Instead every function below takes the plain pid a turn
task actually has in hand — the Connection pid (closed over from the
original dispatch's ctx.conn when the app builds its turn_runner fun)
for the outbound protocol requests, or the Session pid (the task's own
session argument) for the two Session-owned primitives.
What's here
read_text_file/3,4,write_text_file/4,5—fs/read_text_file/fs/write_text_file, sent viaConnection.request/4.request/4blocks its caller, so it is for plain handler-task code; a process that must stay responsive usesasync_request/6instead. A turn task is plain handler-task code, not the Session itself.create_terminal/3,4,terminal_output/3,4,wait_for_terminal_exit/3,4,kill_terminal/3,4,release_terminal/3,4— the fiveterminal/*methods, sameConnection.request/4wrapper.request_permission/3,4— builds aRequestPermissionRequestand delegates toSession.request_permission/2. Fail-closed, always: the ONLY way to get an allow back is a literal{:selected, %SelectedPermissionOutcome{}}decoded from the client's reply; every other terminal (timeout, client error, decode failure, disconnect, asession/cancelracing the ask) resolves{:ok, :cancelled}— deny is the zero value everywhere. This wrapper adds no new fail-closed logic; it is documentation + ergonomics over the Session's existing guarantee.post_update/2— delegates toSession.post_update/2verbatim (same arity, same contract): forwards asession/updatefor the live turn, surfacing{:error, :turn_over}for a straggler post after drain and{:error, :empty_chunk}for an empty-textagent_message_chunk/agent_thought_chunk(never sent to the wire — seesession.ex'sempty_chunk?/1).
Timeouts
Connection.request/4's timeout_ms is pos_integer() — not
:infinity (only async_request/6 accepts that). Every
function below takes a :timeout option (default 30_000ms, except
wait_for_terminal_exit/3,4 which defaults to 30 minutes since waiting
for a command to exit is expected to take a while); pass a larger
:timeout explicitly for a longer-running command rather than relying on
a library-invented unbounded wait.
Summary
Functions
terminal/create — create a terminal and execute command. opts:
:args ([String.t()]), :env (list of {name, value} string pairs or
pre-built EnvVariable.t() structs), :cwd, :output_byte_limit,
:timeout (default 30000ms). Only sent if the client
declared the terminal capability.
terminal/kill — kill the terminal's command without releasing the terminal. opts: :timeout.
Emit a session/update for the live turn — a verbatim pass-through to
Session.post_update/2 (same arity, same contract): forwards notification
via the Connection's notify/3 on the Session's own FIFO lane, returns
{:error, :turn_over} for a straggler post after the turn has drained,
and {:error, :empty_chunk} for an empty-text agent_message_chunk/
agent_thought_chunk — the streaming guard lives in session.ex
(empty_chunk?/1); this wrapper adds no logic of its own, only the
re-export so handler code has one module to alias.
fs/read_text_file — read a text file on the client side. opts:
:line (1-based start line), :limit (max lines), :timeout
(default 30000ms). Only sent if the client declared the
fs.readTextFile capability (the client answers -32601 otherwise —
MethodTable's capability gate, unrelated to this wrapper).
terminal/release — release a terminal and free its resources. opts: :timeout.
Ask the client for permission to perform tool_call (session/request_permission),
offering options. Delegates to Session.request_permission/2 — session
is the turn's own Session pid (the task's session argument), NOT the
Connection. Fail-closed: returns
{:ok, {:selected, %SelectedPermissionOutcome{}}} ONLY on a decoded
selected outcome; every other terminal — timeout, client error reply,
undecodable reply, transport/connection death, or a session/cancel
racing the ask — returns {:ok, :cancelled}. There is no third outcome
and no exception path: deny is the zero value everywhere. The Session
arms no timer of its own for this wait; Connection.async_request/6
is the single timeout authority, configured per-connection via
the Session's :permission_timeout (default 600s).
terminal/output — the output captured so far, and exit status if the command has completed. opts: :timeout.
terminal/wait_for_exit — block until the terminal command exits. opts:
:timeout (default 1800000ms = 30 minutes, since
waiting for a command is expected to take a while; request/4 does not
accept :infinity — pass an explicit larger :timeout for a
longer-running command instead).
fs/write_text_file — write content to a text file on the client side.
opts: :timeout (default 30000ms). Only sent if the
client declared the fs.writeTextFile capability.
Functions
@spec create_terminal(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.CreateTerminalResponse.t()} | {:error, term()}
terminal/create — create a terminal and execute command. opts:
:args ([String.t()]), :env (list of {name, value} string pairs or
pre-built EnvVariable.t() structs), :cwd, :output_byte_limit,
:timeout (default 30000ms). Only sent if the client
declared the terminal capability.
@spec kill_terminal(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.KillTerminalResponse.t()} | {:error, term()}
terminal/kill — kill the terminal's command without releasing the terminal. opts: :timeout.
@spec post_update( GenServer.server(), Raxol.AgentClientProtocol.Schema.LifecycleExtras.SessionNotification.t() ) :: :ok | {:error, :turn_over} | {:error, :empty_chunk}
Emit a session/update for the live turn — a verbatim pass-through to
Session.post_update/2 (same arity, same contract): forwards notification
via the Connection's notify/3 on the Session's own FIFO lane, returns
{:error, :turn_over} for a straggler post after the turn has drained,
and {:error, :empty_chunk} for an empty-text agent_message_chunk/
agent_thought_chunk — the streaming guard lives in session.ex
(empty_chunk?/1); this wrapper adds no logic of its own, only the
re-export so handler code has one module to alias.
@spec read_text_file(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReadTextFileResponse.t()} | {:error, term()}
fs/read_text_file — read a text file on the client side. opts:
:line (1-based start line), :limit (max lines), :timeout
(default 30000ms). Only sent if the client declared the
fs.readTextFile capability (the client answers -32601 otherwise —
MethodTable's capability gate, unrelated to this wrapper).
@spec release_terminal(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.ReleaseTerminalResponse.t()} | {:error, term()}
terminal/release — release a terminal and free its resources. opts: :timeout.
@spec request_permission( GenServer.server(), String.t(), Raxol.AgentClientProtocol.Schema.ToolCallUpdate.t(), [Raxol.AgentClientProtocol.Schema.ClientTypes.PermissionOption.t()] ) :: {:ok, {:selected, Raxol.AgentClientProtocol.Schema.ClientTypes.SelectedPermissionOutcome.t()}} | {:ok, :cancelled}
Ask the client for permission to perform tool_call (session/request_permission),
offering options. Delegates to Session.request_permission/2 — session
is the turn's own Session pid (the task's session argument), NOT the
Connection. Fail-closed: returns
{:ok, {:selected, %SelectedPermissionOutcome{}}} ONLY on a decoded
selected outcome; every other terminal — timeout, client error reply,
undecodable reply, transport/connection death, or a session/cancel
racing the ask — returns {:ok, :cancelled}. There is no third outcome
and no exception path: deny is the zero value everywhere. The Session
arms no timer of its own for this wait; Connection.async_request/6
is the single timeout authority, configured per-connection via
the Session's :permission_timeout (default 600s).
@spec terminal_output(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.TerminalOutputResponse.t()} | {:error, term()}
terminal/output — the output captured so far, and exit status if the command has completed. opts: :timeout.
@spec wait_for_terminal_exit(pid(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WaitForTerminalExitResponse.t()} | {:error, term()}
terminal/wait_for_exit — block until the terminal command exits. opts:
:timeout (default 1800000ms = 30 minutes, since
waiting for a command is expected to take a while; request/4 does not
accept :infinity — pass an explicit larger :timeout for a
longer-running command instead).
@spec write_text_file(pid(), String.t(), String.t(), String.t(), keyword()) :: {:ok, Raxol.AgentClientProtocol.Schema.ClientTypes.WriteTextFileResponse.t()} | {:error, term()}
fs/write_text_file — write content to a text file on the client side.
opts: :timeout (default 30000ms). Only sent if the
client declared the fs.writeTextFile capability.