Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ Start the proxy in one terminal:
claude-code-proxy serve
```

For a background service, use `claude-code-proxy serve --no-monitor` and attach
from another terminal with `claude-code-proxy monitor`. Closing an attached
dashboard leaves the proxy running.

Start Claude Code in another:

```sh
Expand Down
10 changes: 10 additions & 0 deletions docs/src/content/docs/reference/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ Starts the local HTTP proxy and blocks until shutdown.

The bind address comes from `CCP_BIND_ADDRESS` or `bindAddress`. Interactive stdout opens the monitor unless `--no-monitor` is present. Non-terminal stdout uses plain mode.

Plain mode continues collecting monitor history and supports separate dashboards. SIGTERM (Unix) and Ctrl-C request graceful service shutdown.

## `monitor`

```sh
claude-code-proxy monitor [--url <URL>]
```

Attach a read-only dashboard to a running proxy. The default URL is `http://127.0.0.1:<configured-port>`. No provider login is needed in the dashboard process. `q` and `Ctrl-C` detach without stopping the proxy; multiple dashboards are supported. After a connection failure, the dashboard retains its last snapshot and retries. The proxy accepts monitor reads only from loopback peers; use an SSH port forward for another machine.

## `demo`

```sh
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/reference/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ Liveness check:

It does not verify provider credentials or upstream availability.

## `GET /monitor`

Read-only dashboard snapshot, available when monitor collection is enabled. The CLI enables collection in both plain and interactive `serve` modes. Only actual loopback peers are allowed; forwarded IP headers do not grant access. Other peers receive HTTP 403, and disabled collection returns HTTP 404. Responses use `Cache-Control: no-store`.

The JSON envelope contains `version: 1` and a `snapshot` with the proxy start time, sessions, active requests and recent requests. It includes the monitor's display metadata, timing, token usage and computed throughput. It does not include credentials or request/response bodies. Process-local monotonic clocks remain in the service; snapshots contain elapsed durations instead.

The `monitor` CLI polls this endpoint. There is no corresponding mutation or shutdown endpoint. Attaching or disconnecting a viewer does not change proxy lifetime or request accounting.

## `POST /v1/messages`

Accepts an Anthropic Messages request in streaming or non-streaming mode. `POST /v1/messages?beta=true` reaches the same route.
Expand Down
24 changes: 22 additions & 2 deletions docs/src/content/docs/using/monitor-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ description: Use the claude-code-proxy monitor to inspect sessions, active and r

`claude-code-proxy serve` opens the monitor when stdout is an interactive terminal. The same process runs the HTTP listener.

To run the proxy as a service and attach the dashboard separately:

```sh
# Run this under your service manager, or leave it in another terminal.
claude-code-proxy serve --no-monitor

# Attach from any terminal; repeat for additional dashboards.
claude-code-proxy monitor
```

Use `claude-code-proxy monitor --url http://127.0.0.1:19999` for a different port. Without `--url`, the port follows the usual proxy configuration. The attached dashboard reads the running service's existing history; it does not start a proxy or need provider credentials.

In an attached dashboard, `q` and `Ctrl-C` detach immediately and leave the service running. Multiple dashboards can attach independently. If the service becomes unavailable, the dashboard marks its last snapshot as stale and reconnects automatically. Network polling runs outside the terminal event loop.

![claude-code-proxy monitor showing sessions, active requests, recent requests, and events](/monitor-tui.webp)

## What the monitor shows
Expand All @@ -27,8 +41,8 @@ description: Use the claude-code-proxy monitor to inspect sessions, active and r
| `Esc` | Close details or an overlay |
| `?` | Toggle shortcut help |
| `b` | Toggle the setup overlay |
| `q` | Request a graceful shutdown |
| `Ctrl-C` | Force shutdown |
| `q` | Detach an attached dashboard; in the built-in dashboard, confirm proxy shutdown |
| `Ctrl-C` | Detach an attached dashboard; in the built-in dashboard, start shutdown (press again to force exit) |

The request table changes columns as the terminal width changes.

Expand All @@ -42,6 +56,8 @@ claude-code-proxy serve --no-monitor

Non-terminal stdout also selects plain mode. `CCP_LOG_STDERR=1` mirrors JSONL log events to stderr in plain mode.

Plain mode retains monitor accounting even with no dashboard attached. On Unix, SIGTERM starts graceful proxy shutdown; Ctrl-C does the same. The service manager owns the process lifetime.

## Demo mode

Explore the full interface without binding a port or using provider credentials:
Expand All @@ -61,3 +77,7 @@ brew services start claude-code-proxy
```

Service output lives in `~/.local/state/claude-code-proxy/service.log` on macOS and Linux. The structured `proxy.log` shares the state directory. Provider login remains an interactive one-time command.

Run `claude-code-proxy monitor` to inspect that service. The monitor endpoint only accepts loopback connections, even if the inference listener binds a LAN address. For a service on another machine, forward its port with SSH and point `monitor --url` at the local end of the tunnel.

History remains in the proxy's memory and resets when the proxy restarts. The dashboard polls snapshots every 250 ms and displays server-computed durations and throughput. The attached setup overlay describes its connection; provider setup remains with the service and its built-in dashboard.
58 changes: 56 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ enum Commands {
#[arg(long = "no-monitor", action = ArgAction::SetTrue)]
no_monitor: bool,
},
/// Attach a read-only dashboard to a running proxy
Monitor {
#[arg(long)]
url: Option<reqwest::Url>,
},
/// Open the monitor TUI with mock data and no proxy server
#[command(hide = true)]
Demo,
Expand Down Expand Up @@ -105,10 +110,10 @@ fn main() -> Result<()> {
ServeMode::Plain => {
print_server_banner(&bind_address, effective_port, &registry);
runtime
.block_on(server::serve(ServerConfig {
.block_on(run_service(ServerConfig {
bind_address,
port: effective_port,
monitor: None,
monitor: Some(MonitorHandle::default()),
}))
.map_err(|err| anyhow::anyhow!(err))
}
Expand Down Expand Up @@ -157,6 +162,25 @@ fn main() -> Result<()> {
let registry = Registry::with_default_alias();
tui::run_mock_monitor(config::port(), &registry)
}
Commands::Monitor { url } => {
let url = url.unwrap_or_else(|| {
format!("http://127.0.0.1:{}", config::port())
.parse()
.expect("local proxy URL")
});
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(2))
.build()?;
let monitor = runtime.block_on(
claude_code_proxy::monitor::remote::RemoteMonitor::connect(client, url.clone()),
)?;
tui::run_attached_monitor(|| monitor.snapshot(), url.to_string())?;
Ok(())
}
Commands::Models { full } => {
print_models(&Registry::with_default_alias(), full);
Ok(())
Expand All @@ -168,6 +192,36 @@ fn main() -> Result<()> {
}
}

async fn run_service(config: ServerConfig) -> Result<()> {
let (shutdown, stopped) = tokio::sync::oneshot::channel();
let server = server::serve_with_shutdown(config, async {
let _ = stopped.await;
});
tokio::pin!(server);
tokio::select! {
result = &mut server => result,
signal = service_shutdown_signal() => {
signal?;
let _ = shutdown.send(());
server.await
}
}
}

#[cfg(unix)]
async fn service_shutdown_signal() -> std::io::Result<()> {
let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
tokio::select! {
result = tokio::signal::ctrl_c() => result,
_ = terminate.recv() => Ok(()),
}
}

#[cfg(not(unix))]
async fn service_shutdown_signal() -> std::io::Result<()> {
tokio::signal::ctrl_c().await
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ServeMode {
Monitor,
Expand Down
11 changes: 8 additions & 3 deletions src/monitor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@ use std::{
};

mod mock;
pub mod remote;
pub mod snapshot;

pub use mock::{MockMonitor, mock_state};

const DEFAULT_RECENT_LIMIT: usize = 200;
pub const SESSION_TOKEN_BUCKET_SECS: u64 = 10;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EndpointKind {
Messages,
CountTokens,
Expand All @@ -35,7 +38,8 @@ impl EndpointKind {
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequestStatus {
Started,
ProviderSelected,
Expand Down Expand Up @@ -210,7 +214,8 @@ impl CompletedRequest {
}
}

#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "unit", content = "value", rename_all = "snake_case")]
pub enum Throughput {
TokensPerSecond(f64),
BytesPerSecond(f64),
Expand Down
86 changes: 86 additions & 0 deletions src/monitor/remote.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
use std::time::Duration;

use anyhow::{Context, Result, bail};
use reqwest::{Client, Url};
use tokio::{sync::watch, task::JoinHandle, time::MissedTickBehavior};

use super::snapshot::{MonitorResponse, MonitorSnapshot, PROTOCOL_VERSION, SnapshotUpdate};

const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024;
const POLL_INTERVAL: Duration = Duration::from_millis(250);

/// Owns polling; dropping the dashboard cancels only its outstanding read.
pub struct RemoteMonitor {
updates: watch::Receiver<SnapshotUpdate>,
poller: JoinHandle<()>,
}

impl RemoteMonitor {
pub async fn connect(client: Client, base_url: Url) -> Result<Self> {
if !matches!(base_url.scheme(), "http" | "https")
|| !base_url.username().is_empty()
|| base_url.password().is_some()
|| base_url.query().is_some()
|| base_url.fragment().is_some()
{
bail!("monitor URL must be an HTTP(S) base URL without credentials, query or fragment");
}
let endpoint = base_url.join("monitor").context("invalid monitor URL")?;
let initial = fetch_snapshot(&client, &endpoint)
.await
.context("cannot attach to proxy monitor")?;
let (sender, updates) = watch::channel(SnapshotUpdate::Live(initial.clone()));
let poller = tokio::spawn(async move {
let mut current = SnapshotUpdate::Live(initial);
let mut interval = tokio::time::interval(POLL_INTERVAL);
interval.set_missed_tick_behavior(MissedTickBehavior::Skip);
interval.tick().await;
loop {
interval.tick().await;
let result = fetch_snapshot(&client, &endpoint)
.await
.map_err(|error| error.to_string());
current = current.updated(result);
if sender.send(current.clone()).is_err() {
break;
}
}
});
Ok(Self { updates, poller })
}

pub fn snapshot(&self) -> SnapshotUpdate {
self.updates.borrow().clone()
}
}

impl Drop for RemoteMonitor {
fn drop(&mut self) {
self.poller.abort();
}
}

async fn fetch_snapshot(client: &Client, endpoint: &Url) -> Result<MonitorSnapshot> {
let mut response = client
.get(endpoint.clone())
.send()
.await?
.error_for_status()?;
let mut body = Vec::new();
while let Some(chunk) = response.chunk().await? {
if body.len().saturating_add(chunk.len()) > MAX_SNAPSHOT_BYTES {
bail!("monitor snapshot exceeds the size limit");
}
body.extend_from_slice(&chunk);
}
let response: MonitorResponse =
serde_json::from_slice(&body).context("invalid monitor snapshot")?;
if response.version != PROTOCOL_VERSION {
bail!(
"unsupported monitor protocol version {}; expected {}",
response.version,
PROTOCOL_VERSION
);
}
Ok(response.snapshot)
}
Loading