diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py
index cb07767e..273e5061 100644
--- a/cli/planoai/config_generator.py
+++ b/cli/planoai/config_generator.py
@@ -39,11 +39,64 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
+# Local-only bridge that runs Claude Code CLI as a subprocess. Hosted by
+# brightstaff on this loopback address; the Python CLI auto-fills the matching
+# provider fields below and tells the launcher to enable the bridge.
+CLAUDE_CLI_DEFAULT_BASE_URL = "http://127.0.0.1:14001"
+CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
+CLAUDE_CLI_DEFAULT_NAME = "claude-cli/*"
+CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER = "claude-cli-local"
+
SUPPORTED_PROVIDERS = (
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
)
+def _is_claude_cli_provider(model_provider):
+ """Return True iff this provider entry refers to the local claude-cli
+ bridge. Triggered by any of `model`, `name`, or `provider_interface`
+ matching the `claude-cli/...` namespace.
+ """
+ model = (model_provider.get("model") or "").strip()
+ name = (model_provider.get("name") or "").strip()
+ interface = (model_provider.get("provider_interface") or "").strip()
+ return (
+ model.startswith("claude-cli/")
+ or name.startswith("claude-cli/")
+ or interface == "claude-cli"
+ )
+
+
+def _apply_claude_cli_autofill(model_provider):
+ """Fill in implicit fields for `claude-cli/*` provider entries so the
+ user only has to write `model: claude-cli/*` (or any `claude-cli/...`)
+ and everything else is wired automatically: a localhost cluster pointing
+ at the brightstaff bridge, the `claude-cli` provider_interface, and a
+ placeholder access key so downstream validation does not reject the entry.
+
+ Returns True iff this entry was treated as a claude-cli provider (so the
+ caller can flip the launcher's `needs_claude_cli_runtime` flag).
+ """
+ if not _is_claude_cli_provider(model_provider):
+ return False
+
+ if not model_provider.get("name"):
+ model_provider["name"] = model_provider.get("model") or CLAUDE_CLI_DEFAULT_NAME
+ if not model_provider.get("provider_interface"):
+ model_provider["provider_interface"] = "claude-cli"
+ if not model_provider.get("base_url"):
+ model_provider["base_url"] = CLAUDE_CLI_DEFAULT_BASE_URL
+ # Keep passthrough_auth users alone; the bridge ignores the access key
+ # anyway (it uses the host's `claude auth login` keychain), so a
+ # placeholder is fine for everyone else.
+ if not model_provider.get("access_key") and not model_provider.get(
+ "passthrough_auth"
+ ):
+ model_provider["access_key"] = CLAUDE_CLI_DEFAULT_ACCESS_KEY_PLACEHOLDER
+
+ return True
+
+
def get_endpoint_and_port(endpoint, protocol):
endpoint_tokens = endpoint.split(":")
if len(endpoint_tokens) > 1:
@@ -329,6 +382,12 @@ def validate_and_render_schema():
name = listener.get("name", None)
for model_provider in listener.get("model_providers", []):
+ # Auto-fill the implicit fields for `claude-cli/*` providers
+ # before the rest of the loop runs validation. This makes
+ # `model_providers: [{model: claude-cli/*}]` a fully-formed
+ # entry by the time we reach the wildcard checks below.
+ _apply_claude_cli_autofill(model_provider)
+
if model_provider.get("usage", None):
llms_with_usage.append(model_provider["name"])
if model_provider.get("name") in model_provider_name_set:
diff --git a/cli/planoai/native_runner.py b/cli/planoai/native_runner.py
index 1b58b36d..91a8f253 100644
--- a/cli/planoai/native_runner.py
+++ b/cli/planoai/native_runner.py
@@ -22,6 +22,61 @@ from planoai.utils import find_repo_root, getLogger
log = getLogger(__name__)
+CLAUDE_CLI_DEFAULT_LISTEN_ADDR = "127.0.0.1:14001"
+# Env vars the user can set to customize the bridge. We always honor a
+# pre-set CLAUDE_CLI_LISTEN_ADDR (so power users can move the listener)
+# but otherwise inject the default whenever a claude-cli provider is
+# detected in the rendered config.
+CLAUDE_CLI_PASSTHROUGH_ENV = (
+ "CLAUDE_CLI_LISTEN_ADDR",
+ "CLAUDE_CLI_BIN",
+ "CLAUDE_CLI_PERMISSION_MODE",
+ "CLAUDE_CLI_SESSION_TTL_SECS",
+ "CLAUDE_CLI_WATCHDOG_SECS",
+ "CLAUDE_CLI_MAX_SESSIONS",
+)
+
+
+def _needs_claude_cli_runtime(plano_config_rendered_path) -> bool:
+ """True iff the rendered config has at least one model_provider whose
+ `provider_interface` is `claude-cli`. The Python config_generator
+ auto-fills this field when it sees a `claude-cli/*` model entry, so the
+ detection is one-step regardless of how the user wrote the original
+ provider line.
+ """
+ import yaml
+
+ try:
+ with open(plano_config_rendered_path, "r") as f:
+ rendered = yaml.safe_load(f) or {}
+ except FileNotFoundError:
+ return False
+ for provider in rendered.get("model_providers") or []:
+ if (provider or {}).get("provider_interface") == "claude-cli":
+ return True
+ return False
+
+
+def _apply_claude_cli_env(brightstaff_env, plano_config_rendered_path):
+ """If the rendered config opts into the claude-cli bridge, ensure
+ `CLAUDE_CLI_LISTEN_ADDR` is set in the brightstaff process environment so
+ the bridge listener actually starts. Honors any pre-set values from the
+ caller's env (so users can override the listen address, binary path, or
+ permission mode without editing this file).
+ """
+ if not _needs_claude_cli_runtime(plano_config_rendered_path):
+ return False
+ if not brightstaff_env.get("CLAUDE_CLI_LISTEN_ADDR"):
+ brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"] = CLAUDE_CLI_DEFAULT_LISTEN_ADDR
+ for key in CLAUDE_CLI_PASSTHROUGH_ENV:
+ if key in os.environ and key not in brightstaff_env:
+ brightstaff_env[key] = os.environ[key]
+ log.info(
+ "claude-cli bridge enabled: brightstaff will listen on %s",
+ brightstaff_env["CLAUDE_CLI_LISTEN_ADDR"],
+ )
+ return True
+
def _find_config_dir():
"""Locate the directory containing plano_config_schema.yaml and envoy.template.yaml.
@@ -197,6 +252,11 @@ def start_native(
for key, value in env.items():
brightstaff_env[key] = value
+ # Enable the claude-cli bridge if the rendered config asks for it. Done
+ # after `env.items()` is merged so user-set CLAUDE_CLI_* env vars take
+ # precedence over the auto-injected defaults.
+ _apply_claude_cli_env(brightstaff_env, plano_config_rendered_path)
+
brightstaff_pid = _daemon_exec(
[brightstaff_path],
brightstaff_env,
diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py
index 77b5b480..e1ba5a74 100644
--- a/cli/test/test_config_generator.py
+++ b/cli/test/test_config_generator.py
@@ -3,8 +3,11 @@ import pytest
import yaml
from unittest import mock
from planoai.config_generator import (
- validate_and_render_schema,
+ CLAUDE_CLI_DEFAULT_BASE_URL,
+ _apply_claude_cli_autofill,
+ _is_claude_cli_provider,
migrate_inline_routing_preferences,
+ validate_and_render_schema,
)
@@ -738,3 +741,64 @@ model_providers:
migrate_inline_routing_preferences(config_yaml)
assert config_yaml["version"] == "v0.5.0"
+
+
+def test_claude_cli_autofill_wildcard_provider():
+ provider = {"model": "claude-cli/*"}
+ assert _is_claude_cli_provider(provider) is True
+ assert _apply_claude_cli_autofill(provider) is True
+ assert provider["name"] == "claude-cli/*"
+ assert provider["provider_interface"] == "claude-cli"
+ assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
+ assert provider["access_key"] == "claude-cli-local"
+ # `model` itself must not be rewritten — the wildcard expansion happens
+ # downstream and we want to preserve the user's intent.
+ assert provider["model"] == "claude-cli/*"
+
+
+def test_claude_cli_autofill_specific_model():
+ provider = {"model": "claude-cli/sonnet", "default": True}
+ assert _apply_claude_cli_autofill(provider) is True
+ assert provider["name"] == "claude-cli/sonnet"
+ assert provider["provider_interface"] == "claude-cli"
+ assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
+ # Existing fields like `default` survive.
+ assert provider["default"] is True
+
+
+def test_claude_cli_autofill_does_not_override_user_fields():
+ provider = {
+ "model": "claude-cli/*",
+ "name": "custom-name",
+ "base_url": "http://192.0.2.10:9000",
+ "access_key": "do-not-touch",
+ }
+ assert _apply_claude_cli_autofill(provider) is True
+ assert provider["name"] == "custom-name"
+ assert provider["base_url"] == "http://192.0.2.10:9000"
+ assert provider["access_key"] == "do-not-touch"
+ # provider_interface still gets injected because it was missing.
+ assert provider["provider_interface"] == "claude-cli"
+
+
+def test_claude_cli_autofill_skips_non_matching_providers():
+ provider = {"model": "openai/gpt-4o"}
+ assert _is_claude_cli_provider(provider) is False
+ assert _apply_claude_cli_autofill(provider) is False
+ assert "provider_interface" not in provider
+
+
+def test_claude_cli_autofill_passthrough_auth_skips_access_key():
+ provider = {"model": "claude-cli/*", "passthrough_auth": True}
+ assert _apply_claude_cli_autofill(provider) is True
+ # Honor passthrough_auth: do not inject a placeholder access_key.
+ assert "access_key" not in provider
+ assert provider["passthrough_auth"] is True
+
+
+def test_claude_cli_autofill_detects_via_provider_interface_only():
+ provider = {"model": "sonnet", "provider_interface": "claude-cli"}
+ assert _is_claude_cli_provider(provider) is True
+ assert _apply_claude_cli_autofill(provider) is True
+ assert provider["base_url"] == CLAUDE_CLI_DEFAULT_BASE_URL
+ assert provider["name"] == "sonnet"
diff --git a/cli/test/test_native_runner_claude_cli.py b/cli/test/test_native_runner_claude_cli.py
new file mode 100644
index 00000000..a7bb495e
--- /dev/null
+++ b/cli/test/test_native_runner_claude_cli.py
@@ -0,0 +1,112 @@
+"""Unit tests for the claude-cli env wiring in native_runner.py."""
+
+import os
+import textwrap
+
+from planoai.native_runner import (
+ CLAUDE_CLI_DEFAULT_LISTEN_ADDR,
+ _apply_claude_cli_env,
+ _needs_claude_cli_runtime,
+)
+
+
+def _write(path, body):
+ path.write_text(textwrap.dedent(body).lstrip())
+ return str(path)
+
+
+def test_needs_claude_cli_runtime_detects_provider(tmp_path):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ version: v0.4.0
+ listeners: []
+ model_providers:
+ - name: claude-cli/*
+ model: '*'
+ provider_interface: claude-cli
+ base_url: http://127.0.0.1:14001
+ """,
+ )
+ assert _needs_claude_cli_runtime(rendered) is True
+
+
+def test_needs_claude_cli_runtime_skips_other_providers(tmp_path):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ version: v0.4.0
+ model_providers:
+ - name: openai/gpt-4o
+ model: gpt-4o
+ provider_interface: openai
+ """,
+ )
+ assert _needs_claude_cli_runtime(rendered) is False
+
+
+def test_needs_claude_cli_runtime_handles_missing_file(tmp_path):
+ assert _needs_claude_cli_runtime(str(tmp_path / "does-not-exist.yaml")) is False
+
+
+def test_apply_claude_cli_env_injects_default_addr(tmp_path, monkeypatch):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ model_providers:
+ - provider_interface: claude-cli
+ model: '*'
+ """,
+ )
+ monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
+ monkeypatch.delenv("CLAUDE_CLI_BIN", raising=False)
+ env = {}
+ assert _apply_claude_cli_env(env, rendered) is True
+ assert env["CLAUDE_CLI_LISTEN_ADDR"] == CLAUDE_CLI_DEFAULT_LISTEN_ADDR
+
+
+def test_apply_claude_cli_env_honors_user_override(tmp_path, monkeypatch):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ model_providers:
+ - provider_interface: claude-cli
+ model: '*'
+ """,
+ )
+ monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
+ env = {"CLAUDE_CLI_LISTEN_ADDR": "127.0.0.1:25000"}
+ assert _apply_claude_cli_env(env, rendered) is True
+ assert env["CLAUDE_CLI_LISTEN_ADDR"] == "127.0.0.1:25000"
+
+
+def test_apply_claude_cli_env_passes_through_user_env(tmp_path, monkeypatch):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ model_providers:
+ - provider_interface: claude-cli
+ model: '*'
+ """,
+ )
+ monkeypatch.delenv("CLAUDE_CLI_LISTEN_ADDR", raising=False)
+ monkeypatch.setenv("CLAUDE_CLI_BIN", "/usr/local/bin/claude-test")
+ monkeypatch.setenv("CLAUDE_CLI_PERMISSION_MODE", "default")
+ env = {}
+ assert _apply_claude_cli_env(env, rendered) is True
+ assert env["CLAUDE_CLI_BIN"] == "/usr/local/bin/claude-test"
+ assert env["CLAUDE_CLI_PERMISSION_MODE"] == "default"
+
+
+def test_apply_claude_cli_env_noop_for_other_configs(tmp_path):
+ rendered = _write(
+ tmp_path / "rendered.yaml",
+ """
+ model_providers:
+ - provider_interface: openai
+ model: gpt-4o
+ """,
+ )
+ env = {}
+ assert _apply_claude_cli_env(env, rendered) is False
+ assert "CLAUDE_CLI_LISTEN_ADDR" not in env
diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml
index 9560b437..10ad86ce 100644
--- a/config/plano_config_schema.yaml
+++ b/config/plano_config_schema.yaml
@@ -184,6 +184,7 @@ properties:
enum:
- plano
- claude
+ - claude-cli
- deepseek
- groq
- mistral
@@ -242,6 +243,7 @@ properties:
enum:
- plano
- claude
+ - claude-cli
- deepseek
- groq
- mistral
diff --git a/config/supervisord.conf b/config/supervisord.conf
index a2869136..f2095c8e 100644
--- a/config/supervisord.conf
+++ b/config/supervisord.conf
@@ -18,8 +18,16 @@ stdout_logfile_maxbytes=0
stderr_logfile_maxbytes=0
[program:brightstaff]
+# CLAUDE_CLI_LISTEN_ADDR is set automatically when the rendered config has at
+# least one provider with `provider_interface: claude-cli` (the Python config
+# generator auto-fills that field for any `model: claude-cli/*` entry). The
+# bridge listener stays off otherwise — matches native_runner.py behavior.
command=sh -c "\
while [ ! -f /tmp/config_ready ]; do echo '[brightstaff] Waiting for config generation...'; sleep 0.5; done && \
+ if grep -q 'provider_interface: claude-cli' /app/plano_config_rendered.env_sub.yaml 2>/dev/null; then \
+ export CLAUDE_CLI_LISTEN_ADDR=${CLAUDE_CLI_LISTEN_ADDR:-127.0.0.1:14001}; \
+ echo '[brightstaff] claude-cli bridge enabled on '$CLAUDE_CLI_LISTEN_ADDR; \
+ fi; \
RUST_LOG=${LOG_LEVEL:-info} \
PLANO_CONFIG_PATH_RENDERED=/app/plano_config_rendered.env_sub.yaml \
/app/brightstaff 2>&1 | \
diff --git a/crates/brightstaff/src/handlers/claude_cli/mod.rs b/crates/brightstaff/src/handlers/claude_cli/mod.rs
new file mode 100644
index 00000000..89fff8ee
--- /dev/null
+++ b/crates/brightstaff/src/handlers/claude_cli/mod.rs
@@ -0,0 +1,22 @@
+//! Bridge that exposes the local `claude` CLI as an Anthropic Messages API
+//! endpoint on a localhost port, allowing it to be used as just another
+//! `model_provider` in Plano.
+//!
+//! Wire-up:
+//! - `process` — spawns and manages the `claude -p --output-format stream-json
+//! --input-format stream-json` subprocess.
+//! - `session` — keys long-lived processes by session id (header or hash) and
+//! enforces idle TTL / cap.
+//! - `server` — hyper listener that speaks `POST /v1/messages` and bridges
+//! between Anthropic SSE and the CLI's NDJSON.
+//!
+//! Translation between the two wire formats lives in
+//! `hermesllm::apis::claude_cli`; this module only owns runtime concerns.
+
+pub mod process;
+pub mod server;
+pub mod session;
+
+pub use process::{ClaudeCliConfig, ClaudeProcess, ProcessError};
+pub use server::run_listener;
+pub use session::{SessionManager, SessionManagerConfig, SESSION_HEADER};
diff --git a/crates/brightstaff/src/handlers/claude_cli/process.rs b/crates/brightstaff/src/handlers/claude_cli/process.rs
new file mode 100644
index 00000000..6a19943c
--- /dev/null
+++ b/crates/brightstaff/src/handlers/claude_cli/process.rs
@@ -0,0 +1,330 @@
+//! Manages the lifetime of one `claude -p` child process for a single
+//! conversation session. Spawning, env scrubbing, NDJSON line reading and the
+//! per-line watchdog all live here. Translation between Anthropic Messages
+//! and stream-json lives in `hermesllm::apis::claude_cli`.
+
+use std::process::Stdio;
+use std::sync::Arc;
+use std::time::Duration;
+
+use hermesllm::apis::claude_cli::{parse_ndjson_line, ClaudeCliEvent, ClaudeCliInputEvent};
+use thiserror::Error;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::{Child, ChildStdin, Command};
+use tokio::sync::{mpsc, Mutex, OwnedMutexGuard};
+use tokio::time::{self, Instant};
+use tracing::{debug, info, warn};
+
+/// Tunables for one `ClaudeProcess`. Defaults match the OpenClaw reference
+/// configuration: `bypassPermissions`, ~120 s watchdog window, ~10 min idle TTL.
+#[derive(Debug, Clone)]
+pub struct ClaudeCliConfig {
+ /// Path or name of the `claude` binary (looked up via `$PATH`).
+ pub binary: String,
+ /// Value passed to `--permission-mode`. The CLI accepts `default`,
+ /// `acceptEdits`, `plan`, `auto`, `dontAsk`, `bypassPermissions`.
+ pub permission_mode: String,
+ /// Idle session TTL — after this many seconds without a request the
+ /// session manager kills the child.
+ pub session_ttl: Duration,
+ /// Per-line watchdog: if no NDJSON line arrives for this long during a
+ /// turn, kill the child. Reset on every line (not every byte).
+ pub watchdog: Duration,
+}
+
+impl Default for ClaudeCliConfig {
+ fn default() -> Self {
+ Self {
+ binary: "claude".to_string(),
+ permission_mode: "bypassPermissions".to_string(),
+ session_ttl: Duration::from_secs(600),
+ watchdog: Duration::from_secs(120),
+ }
+ }
+}
+
+/// Errors produced while interacting with the child process.
+#[derive(Debug, Error)]
+pub enum ProcessError {
+ #[error("failed to spawn `{binary}`: {source}")]
+ Spawn {
+ binary: String,
+ #[source]
+ source: std::io::Error,
+ },
+ #[error("failed to write to claude stdin: {0}")]
+ StdinWrite(#[source] std::io::Error),
+ #[error("claude process exited unexpectedly")]
+ ExitedEarly,
+ #[error("claude watchdog fired after {0:?} of silence")]
+ WatchdogTimeout(Duration),
+ #[error("failed to serialize stdin payload: {0}")]
+ Serialize(#[from] serde_json::Error),
+ #[error("turn already in progress for this session")]
+ TurnInProgress,
+}
+
+/// Strip down to the model alias / id the CLI's `--model` flag accepts.
+/// Models registered via the wildcard `claude-cli/*` arrive prefixed with
+/// `claude-cli/` (or just bare, e.g. `sonnet`); both forms are normalized
+/// here.
+pub fn normalize_model_arg(model: &str) -> &str {
+ model.strip_prefix("claude-cli/").unwrap_or(model)
+}
+
+/// Environment variables that must be removed before exec'ing `claude` so the
+/// child uses its own login keychain rather than picking up server-side
+/// credentials. The list mirrors the OpenClaw scrub list.
+const SCRUB_ENV_PREFIXES: &[&str] = &["ANTHROPIC_", "CLAUDE_CODE_", "OTEL_"];
+
+fn scrubbed_env_for_spawn() -> Vec<(String, String)> {
+ std::env::vars()
+ .filter(|(k, _)| !SCRUB_ENV_PREFIXES.iter().any(|p| k.starts_with(p)))
+ .collect()
+}
+
+/// One running `claude -p` subprocess plus the channels we use to talk to it.
+/// Each `ClaudeProcess` is owned by exactly one session.
+pub struct ClaudeProcess {
+ child: Mutex