CrowdControl.Agent.Omp (crowd_control v0.1.1)

Copy Markdown View Source

CrowdControl.Agent adapter for Oh My Pi (omp).

Launches omp --mode rpc, omp's newline-delimited JSON-RPC protocol over stdio, and normalizes its frames into CrowdControl.Protocol.message/0 so subscribers written against Claude Code work unchanged.

Wire mapping

omp frameCrowdControl message
get_state response (sent once at startup){:system_init, map} with "session_id"
message_end (assistant){:assistant, map}
message_end (user){:user, map}
message_update{:stream_event, map}
agent_end with isTerminal != false{:result, "success", map}
agent_end with isTerminal == false{:stream_event, map}
prompt response or prompt_result with agentInvoked: false{:result, "success", map} with "local_only" => true
failed prompt response{:result, "error_prompt_failed", map}
anything else{:unknown, map}

omp acknowledges a prompt command before the turn runs, so the completion signal is agent_end — and only when isTerminal is not false, since a false means maintenance or async delivery will resume the session. An agent_end is turned into a Claude-shaped result map: "result" is the final assistant text, "total_cost_usd" the summed cost of the turn's assistant messages, plus "usage", "num_turns", "duration_ms" and "stop_reason".

A prompt that omp resolves locally — a slash command such as /tools — never produces an agent_end. It completes with agentInvoked: false, which maps to a result carrying "local_only" => true and an empty "result": the command's own text arrives separately as command_output frames, and decode_line/1 is stateless. Without this a local-only prompt would hang a collector until its deadline.

The adapter stays on protocol v1 (no negotiate_protocol), so an oversized logical frame is truncated by omp rather than chunked; :max_line_bytes on the session still bounds what a single line may cost.

Credentials

Everything below travels through the session's validated environment — a 0600 env file locally, the exec Env array remotely. Never argv, never ps.

You havePassSets
An Anthropic API key:api_keyANTHROPIC_API_KEY
A Claude subscription, headless:oauth_tokenANTHROPIC_OAUTH_TOKEN, which omp resolves ahead of the API key
A Claude subscription already logged in on this hostnothing — it just worksomp reads ~/.omp/agent/agent.db
A self-hosted endpoint (vLLM, SGLang, LiteLLM)custom_provider: [base_url: …, api_key: …]a generated models.yml plus one env var
Any other provider (OpenAI, Groq, …)env: %{"OPENAI_API_KEY" => …}that variable verbatim

:api_key and :api_url are Anthropic-specific shorthands inherited from CrowdControl.CLI; for any other hosted provider use :env with the variable omp documents for it.

Subscription passthrough

A session with no :custom_provider uses omp's real agent directory, so a /login you have already done applies with no configuration at all — that is the easiest way to bill sessions to a Claude subscription.

Two cases need help:

  • No stored login (CI, a container, another user): mint a token with omp and pass it as :oauth_token.
  • :custom_provider is in play: it relocates the agent directory (see below), which leaves the stored login behind. Add inherit_auth: true to the spec to link the real store back in, and the session can reach both its custom endpoint and Anthropic.

inherit_auth is opt-in rather than the default on purpose: it symlinks your OAuth store into a directory a session's own bash tool can read, while that session is talking to a third-party endpoint. Turn it on for endpoints you trust; leave it off for ones you do not.

Options

Shared with CrowdControl.CLI (Claude Code): :executable (default "omp"), :model, :system_prompt, :allowed_tools, :permission_mode, :resume, :continue, :add_dir, :no_session_persistence, :extra_args, :env, :api_key, :api_url.

:permission_mode is translated to omp's approval modes: "bypassPermissions" => yolo, "acceptEdits" => write, "default" => always-ask. "plan" has no approval-mode equivalent and raises; use extra_args: ["--plan-yolo"] if that is what you want.

omp-native options:

  • :approval_mode - "always-ask", "write" or "yolo"; wins over :permission_mode
  • :auto_approve - true to auto-approve every tool call
  • :append_system_prompt - text (or file path) appended to the system prompt
  • :thinking - "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "auto"

  • :provider, :service_tier, :profile, :max_time
  • :cwd, :session_dir - paths (sanitized + expanded)
  • :config, :hooks, :extensions - path or list of paths, one flag each
  • :models, :skills - lists joined with ,
  • :no_tools, :no_lsp, :no_pty, :no_extensions, :no_skills, :no_rules, :no_title, :advisor, :allow_home, :hide_thinking - booleans
  • :streaming_behavior - "followUp" (default) or "steer"; how a prompt sent mid-turn is queued
  • :agent_dir - path for PI_CODING_AGENT_DIR, the directory omp reads models.yml and config.yml from (sanitized + expanded)
  • :custom_provider - declarative OpenAI-compatible endpoint; see below
  • :sandbox_agent_dir - ship :custom_provider's rendered files into a remote sandbox instead of rendering them on the host; true for the default in-sandbox directory, or an absolute in-sandbox path. Needs CrowdControl.Backend.Sandboxd; see below
  • :oauth_token - Claude subscription token; sets ANTHROPIC_OAUTH_TOKEN

Claude-Code-only options (:mcp_config, :strict_mcp_config, :agents, :plugin_dir, :settings, :settings_file, :settings_json, :setting_sources, :max_budget_usd, :session_id, :bare) have no omp equivalent and raise ArgumentError rather than being dropped silently. :include_partial_messages is accepted and ignored: RPC mode always streams deltas as message_update frames.

Custom providers (vLLM, LiteLLM, any OpenAI-compatible endpoint)

omp resolves a provider's baseUrl from models.yml under its agent directory — there is no CLI flag for it. :custom_provider renders that file into a private 0700 temp directory and points PI_CODING_AGENT_DIR at it:

CrowdControl.run("Explain this repo",
  agent: :omp,
  custom_provider: [base_url: "http://10.0.0.5:8000/v1"],
  model: "vllm/Qwen/Qwen3-Coder-30B"
)

Spec keys:

  • :base_url - required, the OpenAI-compatible endpoint
  • :id - provider id, default "vllm". omp has a built-in vllm provider that reads max_model_len from /v1/models; any other id is a plain custom provider. The id is the provider/ prefix in :model.
  • :api - default "openai-completions"; use "openai-responses" for a server exposing /v1/responses, or "anthropic-messages"
  • :api_key - provider credential. Never written to models.yml: it is passed through the same validated environment channel as :api_key (a 0600 env file locally, the exec Env array remotely), and the config references it by variable name. Omit it for an unauthenticated server, which renders auth: none.
  • :api_key_env - name of that variable, default "OMP_CUSTOM_PROVIDER_KEY"
  • :models - explicit model list, each [id: ..., name: ..., context_window: ..., max_tokens: ..., reasoning: true | false, input: ["text", "image"]]. Omit to discover models from the server's /v1/models instead.
  • :headers - extra request headers as a string-keyed map
  • :inherit_auth - true to link omp's stored-login database (~/.omp/agent/agent.db) into the generated directory, so the session keeps the logins omp already has despite the relocation. Accepts a path to a specific agent directory instead of true. Off by default — see "Subscription passthrough" above for the trade-off.

The directory is content-addressed, so every session in a fan-out sharing one spec shares one directory rather than writing N copies. Build it yourself with provider_dir!/1 and pass :agent_dir when you want to own the lifecycle (and remove_provider_dir/1 to delete it).

Custom providers on a remote sandbox

A host temp directory is not visible inside a sandbox, so a rendered models.yml has to get there some other way. Which backends solve that and which still leave it to you:

  • CrowdControl.Backend.Sandboxdsolved. Pass sandbox_agent_dir: true and the rendered file is written into the sandbox at /tmp/cc-omp-agent/models.yml, mode 0600, over the agent's PUT /v1/files — after the sandbox exists and before the CLI starts — with PI_CODING_AGENT_DIR pointing there. Pass an absolute in-sandbox path instead of true to choose the directory. Nothing at all is rendered on the host on this path.
  • CrowdControl.Backend.Docker and CrowdControl.Backend.Kubernetesstill yours. Neither has a file-transfer channel, so the directory has to be baked into the image or mounted in (a bind mount, a ConfigMap); render it with provider_dir!/1 and pass :agent_dir naming the path it has inside the container, not on the host.
  • CrowdControl.Backend.Local — never a problem, since the host is the sandbox and :custom_provider alone already works.

:inherit_auth is refused together with :sandbox_agent_dir. omp's auth store is a host file it refreshes in place, so a symlink means nothing across a substrate boundary and a copy would strand refreshed tokens in the sandbox — while handing a live subscription credential to the untrusted code the sandbox exists to contain. Use :oauth_token to bill a subscription from a sandboxed session.

PI_CODING_AGENT_DIR relocates more than models.yml

It moves the whole ~/.omp/agent base for that session: config.yml, the auth store (agent.db), and saved sessions. So a :custom_provider session starts with none of your global omp settings and none of your stored logins — usually what you want for an isolated endpoint. Add inherit_auth: true when you want the logins back, and :config to point at a settings overlay. ~/.omp itself (skills, plugins) is unaffected.

Summary

Functions

Renders a models.yml for a custom provider into a private temp directory and returns its path, for use as :agent_dir.

Removes a directory created by provider_dir!/1.

Renders the models.yml body for a custom-provider spec.

Functions

provider_dir!(spec)

@spec provider_dir!(keyword() | map()) :: String.t()

Renders a models.yml for a custom provider into a private temp directory and returns its path, for use as :agent_dir.

The directory is 0700, the file 0600, and the name is derived from a per-VM random salt plus a digest of the rendered config — so one spec maps to one directory no matter how many sessions share it, and the path is not guessable by another local user. Writing is idempotent.

The spec never carries a secret to disk: :api_key is referenced by environment-variable name (see :api_key_env), and the value itself travels through the session's normal environment channel.

Delete it with remove_provider_dir/1 when the last session using it is done; it is a few hundred bytes, so leaving it until the OS clears the temp directory is also fine.

dir = CrowdControl.Agent.Omp.provider_dir!(base_url: "http://10.0.0.5:8000/v1")
CrowdControl.run("hi", agent: :omp, agent_dir: dir, model: "vllm/my-model")

inherit_auth: true additionally links omp's stored-login database into the directory, so the session keeps the logins omp already has — see the moduledoc for why that is opt-in.

remove_provider_dir(dir)

@spec remove_provider_dir(String.t()) :: :ok | {:error, :not_a_provider_dir}

Removes a directory created by provider_dir!/1.

Refuses anything that is not demonstrably one of ours, so a caller cannot turn a stray option value into a recursive delete: the path must sit directly in the temp directory, carry the cc_omp_ prefix, and — checked without following symlinks — actually be a directory.

render_models_config!(spec)

@spec render_models_config!(keyword() | map()) :: binary()

Renders the models.yml body for a custom-provider spec.

Emitted as JSON, which every YAML parser accepts: it keeps quoting and escaping in JSON.encode!/1 rather than in a hand-rolled emitter.