LemonCliRunners. JsonlRunner behaviour
(lemon_cli_runners v0.1.0)
View Source
Base GenServer for running CLI tools that emit JSONL events.
This module provides the infrastructure for spawning CLI subprocesses, reading their JSONL output, and emitting events through an EventStream.
Architecture
The JsonlRunner is a behaviour module that concrete runners implement. It handles:
- Subprocess spawning with proper signal handling
- JSONL line parsing from stdout
- Concurrent stderr draining
- Graceful shutdown (SIGTERM → SIGKILL)
- Session locking to prevent concurrent runs of the same session
- Event translation to unified CLI runner events
Usage
Implement the callbacks:
defmodule MyRunner do
use LemonCliRunners.JsonlRunner
@impl true
def engine, do: "my_engine"
@impl true
def build_command(prompt, resume, state) do
{"my-cli", ["exec", "--json"]}
end
@impl true
def translate_event(data, state) do
# Convert parsed JSON to CLI runner events
end
endThen start it:
{:ok, pid} = MyRunner.start_link(prompt: "Hello", cwd: "/path")
# Subscribe to events
for event <- LemonAgent.EventStream.events(stream) do
handle_event(event)
endSession Locking
When resuming a session, the runner acquires a lock to prevent concurrent execution. This ensures session consistency when multiple callers try to resume the same session.
Summary
Callbacks
Build the command and arguments to execute
Decode a JSON line into a data structure
Return the engine identifier (e.g., 'codex', 'claude')
Optional environment variables
Handle non-zero exit code
Handle stream end without completion event
Create initial runner state
Create initial runner state with cwd context
Create initial runner state with cwd + raw options context
Return bytes to send to stdin (or nil for no input)
Translate decoded data into CLI runner events.
Functions
Cancel a running runner
Returns a specification to start this module under a supervisor.
Run synchronously and collect all events.
Start a JSONL runner.
Get the event stream from a running runner
Types
Callbacks
@callback build_command( prompt :: String.t(), resume :: resume_option(), state :: runner_state() ) :: {command :: String.t(), args :: [String.t()]}
Build the command and arguments to execute
Decode a JSON line into a data structure
@callback engine() :: String.t()
Return the engine identifier (e.g., 'codex', 'claude')
@callback env(state :: runner_state()) :: [{String.t(), String.t()}] | nil
Optional environment variables
@callback handle_exit_error(exit_code :: integer(), state :: runner_state()) :: {events :: [LemonCliRunners.Types.cli_event()], state :: runner_state()}
Handle non-zero exit code
@callback handle_stream_end(state :: runner_state()) :: {events :: [LemonCliRunners.Types.cli_event()], state :: runner_state()}
Handle stream end without completion event
@callback init_state(prompt :: String.t(), resume :: resume_option()) :: runner_state()
Create initial runner state
@callback init_state(prompt :: String.t(), resume :: resume_option(), cwd :: String.t()) :: runner_state()
Create initial runner state with cwd context
@callback init_state( prompt :: String.t(), resume :: resume_option(), cwd :: String.t(), opts :: keyword() ) :: runner_state()
Create initial runner state with cwd + raw options context
@callback stdin_payload( prompt :: String.t(), resume :: resume_option(), state :: runner_state() ) :: binary() | nil
Return bytes to send to stdin (or nil for no input)
@callback translate_event(data :: term(), state :: runner_state()) :: {events :: [LemonCliRunners.Types.cli_event()], state :: runner_state(), opts :: keyword()}
Translate decoded data into CLI runner events.
Returns a tuple of {events, updated_state, options} where options can include:
:found_session- ResumeToken extracted from this event:done- true if this is the final event
Functions
Cancel a running runner
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec run(module(), [start_option()]) :: {:ok, [term()]} | {:error, term()}
Run synchronously and collect all events.
Returns {:ok, events} or {:error, reason}.
@spec start_link(module(), [start_option()]) :: GenServer.on_start()
Start a JSONL runner.
Options
:prompt- The prompt to send (required):resume- ResumeToken for session continuation (optional):cwd- Working directory (default: current directory):env- Additional environment variables:timeout- Subprocess timeout in ms (default from:lemon_cli_runners, :cli_timeout_ms):owner- Owner process to monitor (default: caller)
Returns {:ok, pid} where pid is the runner GenServer.
Use stream/1 to get the event stream.
@spec stream(pid()) :: LemonAgent.EventStream.t()
Get the event stream from a running runner