Packages
claude_code
0.17.0
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.0
0.34.0
0.33.1
0.32.2
0.32.0
0.31.0
0.30.0
0.29.0
0.28.0
0.27.0
0.26.0
0.25.0
0.24.0
0.23.0
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.0
0.9.0
0.8.1
0.8.0
0.7.0
0.6.0
0.5.0
0.4.0
0.3.0
0.2.0
0.1.0
Claude Agent SDK for Elixir – Build AI agents with Claude Code
Current section
Files
Jump to
Current section
Files
lib/claude_code/session.ex
defmodule ClaudeCode.Session do
@moduledoc """
GenServer that manages Claude Code sessions.
Each session maintains a connection to Claude (via an adapter) and handles
request queuing, subscriber management, and session continuity.
The session uses adapters for communication:
- `ClaudeCode.Adapter.CLI` (default) - Manages a CLI subprocess via Port
- `ClaudeCode.Adapter.Test` - Delivers mock messages for testing
"""
use GenServer
alias ClaudeCode.Message.AssistantMessage
alias ClaudeCode.Message.ResultMessage
alias ClaudeCode.Message.SystemMessage
alias ClaudeCode.Options
alias ClaudeCode.ToolCallback
require Logger
defstruct [
:session_options,
:session_id,
:tool_callback,
:pending_tool_uses,
# Adapter
:adapter_module,
:adapter_opts,
:adapter_pid,
# Request tracking
:requests,
:query_queue,
# Caller chain for test adapter stub lookup
:callers
]
@request_timeout 300_000
# Request tracking structure
defmodule Request do
@moduledoc false
defstruct [
:id,
:subscribers,
:messages,
:status,
:created_at
]
end
# ============================================================================
# Client API
# ============================================================================
@doc """
Starts a new session GenServer.
The session lazily connects to the adapter on the first query.
"""
def start_link(opts) do
{name, session_opts} = Keyword.pop(opts, :name)
# Apply app config defaults and validate options early
opts_with_config = Options.apply_app_config_defaults(session_opts)
case Options.validate_session_options(opts_with_config) do
{:ok, validated_opts} ->
# Capture the caller chain for test adapter stub lookup
callers = [self() | Process.get(:"$callers") || []]
init_opts = validated_opts |> Keyword.put(:name, name) |> Keyword.put(:callers, callers)
case name do
nil -> GenServer.start_link(__MODULE__, init_opts)
_ -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
{:error, validation_error} ->
raise ArgumentError, Exception.message(validation_error)
end
end
# ============================================================================
# Server Callbacks
# ============================================================================
@impl true
def init(validated_opts) do
callers = Keyword.get(validated_opts, :callers, [])
{adapter_module, adapter_opts} = resolve_adapter(validated_opts, callers)
state = %__MODULE__{
session_options: validated_opts,
session_id: Keyword.get(validated_opts, :resume),
tool_callback: Keyword.get(validated_opts, :tool_callback),
pending_tool_uses: %{},
adapter_module: adapter_module,
adapter_opts: adapter_opts,
adapter_pid: nil,
requests: %{},
query_queue: :queue.new(),
callers: callers
}
{:ok, state}
end
@impl true
def handle_call({:query_stream, prompt, opts}, _from, state) do
request = %Request{
id: make_ref(),
subscribers: [],
messages: [],
status: :active,
created_at: System.monotonic_time()
}
case enqueue_or_execute(request, prompt, opts, state) do
{:ok, new_state} ->
{:reply, {:ok, request.id}, new_state}
{:error, reason, new_state} ->
{:reply, {:error, reason}, new_state}
end
end
def handle_call({:receive_next, req_ref}, from, state) do
case Map.get(state.requests, req_ref) do
nil ->
{:reply, {:error, :unknown_request}, state}
%{messages: [msg | rest]} = request ->
updated_request = %{request | messages: rest}
new_requests = Map.put(state.requests, req_ref, updated_request)
{:reply, {:message, msg}, %{state | requests: new_requests}}
%{status: :completed, messages: []} ->
new_requests = Map.delete(state.requests, req_ref)
{:reply, :done, %{state | requests: new_requests}}
%{status: status, messages: []} = request when status in [:active, :queued] ->
updated_request = %{request | subscribers: [from | request.subscribers]}
new_requests = Map.put(state.requests, req_ref, updated_request)
{:noreply, %{state | requests: new_requests}}
end
end
def handle_call(:get_session_id, _from, state) do
{:reply, state.session_id, state}
end
def handle_call(:clear_session, _from, state) do
{:reply, :ok, %{state | session_id: nil}}
end
@impl true
def handle_cast({:stream_cleanup, request_ref}, state) do
new_requests = Map.delete(state.requests, request_ref)
{:noreply, %{state | requests: new_requests}}
end
# ============================================================================
# Adapter Message Handlers
# ============================================================================
@impl true
def handle_info({:adapter_message, request_id, message}, state) do
# Extract session ID if present
new_session_id = extract_session_id(message) || state.session_id
# Process tool callback
{new_pending_tools, _events} =
ToolCallback.process_message(message, state.pending_tool_uses, state.tool_callback)
state = %{state | session_id: new_session_id, pending_tool_uses: new_pending_tools}
# Find the request and dispatch message
case Map.get(state.requests, request_id) do
nil ->
{:noreply, state}
request ->
updated_request = dispatch_message(message, request)
new_requests = Map.put(state.requests, request_id, updated_request)
{:noreply, %{state | requests: new_requests}}
end
end
def handle_info({:adapter_done, request_id}, state) do
case Map.get(state.requests, request_id) do
nil ->
{:noreply, state}
request ->
new_state = complete_request(request_id, request, state)
{:noreply, new_state}
end
end
def handle_info({:adapter_error, request_id, reason}, state) do
case Map.get(state.requests, request_id) do
nil ->
{:noreply, state}
request ->
notify_error(request, reason)
new_requests = Map.put(state.requests, request_id, %{request | status: :completed})
new_state = %{state | requests: new_requests}
{:noreply, process_next_in_queue(new_state)}
end
end
def handle_info({:request_timeout, request_id}, state) do
case Map.get(state.requests, request_id) do
nil ->
{:noreply, state}
request when request.status == :active ->
Logger.warning("Request #{inspect(request_id)} timed out after #{@request_timeout}ms")
notify_error(request, :timeout)
new_requests = Map.put(state.requests, request_id, %{request | status: :completed})
{:noreply, %{state | requests: new_requests}}
_completed ->
{:noreply, state}
end
end
def handle_info(msg, state) do
Logger.debug("Session unhandled message: #{inspect(msg)}")
{:noreply, state}
end
@impl true
def terminate(_reason, state) do
if state.adapter_pid do
state.adapter_module.stop(state.adapter_pid)
end
:ok
rescue
_ -> :ok
end
# ============================================================================
# Private Functions - Adapter Management
# ============================================================================
defp resolve_adapter(opts, callers) do
case Keyword.get(opts, :adapter) do
nil ->
# Default: CLI adapter
{ClaudeCode.Adapter.CLI, opts}
{ClaudeCode.Test, stub_name} ->
# Test adapter with stub name and callers for stub lookup
adapter_opts = opts |> Keyword.put(:stub_name, stub_name) |> Keyword.put(:callers, callers)
{ClaudeCode.Adapter.Test, adapter_opts}
{module, _name} = _adapter ->
# Custom adapter (for future extensibility)
{module, opts}
end
end
defp ensure_adapter(%{adapter_pid: nil} = state) do
case state.adapter_module.start_link(self(), state.adapter_opts) do
{:ok, pid} ->
{:ok, %{state | adapter_pid: pid}}
{:error, reason} ->
Logger.error("Failed to start adapter: #{inspect(reason)}")
{:error, reason}
end
end
defp ensure_adapter(state), do: {:ok, state}
# ============================================================================
# Private Functions - Request Management
# ============================================================================
defp enqueue_or_execute(request, prompt, opts, state) do
case ensure_adapter(state) do
{:ok, connected_state} ->
if has_active_request?(connected_state) do
# Queue this request
queued_request = %{request | status: :queued}
queue = :queue.in({request, prompt, opts}, connected_state.query_queue)
new_requests = Map.put(connected_state.requests, request.id, queued_request)
{:ok, %{connected_state | query_queue: queue, requests: new_requests}}
else
# Execute immediately
execute_request(request, prompt, opts, connected_state)
end
{:error, reason} ->
{:error, reason, state}
end
end
defp has_active_request?(state) do
Enum.any?(state.requests, fn {_ref, req} -> req.status == :active end)
end
defp execute_request(request, prompt, opts, state) do
# Merge options
{:ok, validated_opts} = Options.validate_query_options(opts)
_final_opts = Options.merge_options(state.session_options, validated_opts)
# Send query to adapter
case state.adapter_module.send_query(
state.adapter_pid,
request.id,
prompt,
state.session_id,
validated_opts
) do
:ok ->
schedule_request_timeout(request.id)
{:ok, %{state | requests: Map.put(state.requests, request.id, request)}}
{:error, reason} ->
{:error, reason, state}
end
end
defp process_next_in_queue(state) do
case :queue.out(state.query_queue) do
{{:value, {request, prompt, opts}}, new_queue} ->
new_state = %{state | query_queue: new_queue}
# Get the tracked request and update to active
tracked_request =
case Map.get(state.requests, request.id) do
nil -> request
existing -> %{existing | status: :active}
end
case execute_request(tracked_request, prompt, opts, new_state) do
{:ok, updated_state} ->
updated_state
{:error, reason, updated_state} ->
notify_error(tracked_request, reason)
updated_state
end
{:empty, _queue} ->
state
end
end
defp schedule_request_timeout(request_id) do
Process.send_after(self(), {:request_timeout, request_id}, @request_timeout)
end
# ============================================================================
# Private Functions - Message Handling
# ============================================================================
defp dispatch_message(message, request) do
case request.subscribers do
[subscriber | rest] ->
GenServer.reply(subscriber, {:message, message})
%{request | subscribers: rest}
[] ->
%{request | messages: request.messages ++ [message]}
end
end
defp complete_request(req_ref, request, state) do
# Notify any waiting subscribers
Enum.each(request.subscribers, fn subscriber ->
GenServer.reply(subscriber, :done)
end)
# Mark as completed
new_requests = Map.put(state.requests, req_ref, %{request | status: :completed})
new_state = %{state | requests: new_requests}
process_next_in_queue(new_state)
end
defp notify_error(request, error) do
Enum.each(request.subscribers, fn subscriber ->
GenServer.reply(subscriber, {:error, error})
end)
end
defp extract_session_id(%SystemMessage{session_id: sid}) when not is_nil(sid), do: sid
defp extract_session_id(%AssistantMessage{session_id: sid}) when not is_nil(sid), do: sid
defp extract_session_id(%ResultMessage{session_id: sid}) when not is_nil(sid), do: sid
defp extract_session_id(_), do: nil
end