Skip to content
Merged
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.
119 changes: 117 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,26 @@ 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()
.no_proxy()
.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 +193,85 @@ fn main() -> Result<()> {
}
}

async fn run_service(config: ServerConfig) -> Result<()> {
let mut signals = ServiceShutdownSignals::new()?;
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 = signals.recv() => {
signal?;
let _ = shutdown.send(());
tokio::select! {
result = &mut server => result,
signal = signals.recv() => {
signal?;
std::process::exit(130);
}
}
}
}
}

#[cfg(unix)]
struct ServiceShutdownSignals {
interrupt: tokio::signal::unix::Signal,
terminate: tokio::signal::unix::Signal,
}

#[cfg(unix)]
impl ServiceShutdownSignals {
fn new() -> std::io::Result<Self> {
Ok(Self {
interrupt: tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?,
terminate: tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?,
})
}

async fn recv(&mut self) -> std::io::Result<()> {
tokio::select! {
_ = self.interrupt.recv() => Ok(()),
_ = self.terminate.recv() => Ok(()),
}
}
}

#[cfg(windows)]
struct ServiceShutdownSignals {
ctrl_c: tokio::signal::windows::CtrlC,
}

#[cfg(windows)]
impl ServiceShutdownSignals {
fn new() -> std::io::Result<Self> {
Ok(Self {
ctrl_c: tokio::signal::windows::ctrl_c()?,
})
}

async fn recv(&mut self) -> std::io::Result<()> {
let _ = self.ctrl_c.recv().await;
Ok(())
}
}

#[cfg(not(any(unix, windows)))]
struct ServiceShutdownSignals;

#[cfg(not(any(unix, windows)))]
impl ServiceShutdownSignals {
fn new() -> std::io::Result<Self> {
Ok(Self)
}

async fn recv(&mut self) -> std::io::Result<()> {
tokio::signal::ctrl_c().await
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ServeMode {
Monitor,
Expand Down Expand Up @@ -314,6 +418,17 @@ mod tests {
assert!(matches!(cli.command, Some(Commands::Demo)));
}

#[tokio::test]
async fn shutdown_signal_setup_and_receive_preserve_io_results() {
fn assert_constructor(_: fn() -> std::io::Result<ServiceShutdownSignals>) {}
fn assert_io_future<F: std::future::Future<Output = std::io::Result<()>>>(_: &F) {}

assert_constructor(ServiceShutdownSignals::new);
let mut signals = ServiceShutdownSignals::new().unwrap();
let receive = signals.recv();
assert_io_future(&receive);
}

#[test]
fn listen_url_brackets_ipv6_addresses() {
assert_eq!(listen_url("::1", 18765), "http://[::1]:18765");
Expand Down
32 changes: 29 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 All @@ -235,6 +240,7 @@ impl Throughput {
#[derive(Debug, Clone)]
pub struct MonitorState {
pub started_at: SystemTime,
pub uptime: Duration,
pub sessions: Vec<SessionSummary>,
pub active: Vec<ActiveRequest>,
pub recent: Vec<CompletedRequest>,
Expand Down Expand Up @@ -279,6 +285,7 @@ impl SessionSummary {
#[derive(Debug)]
struct MonitorStore {
started_at: SystemTime,
started_instant: Instant,
active: HashMap<String, ActiveRequest>,
recent: VecDeque<CompletedRequest>,
session_usage: HashMap<Option<String>, SessionUsage>,
Expand Down Expand Up @@ -308,6 +315,7 @@ impl MonitorHandle {
Self {
store: Arc::new(Mutex::new(MonitorStore {
started_at: SystemTime::now(),
started_instant: Instant::now(),
active: HashMap::new(),
recent: VecDeque::new(),
session_usage: HashMap::new(),
Expand All @@ -328,6 +336,7 @@ impl MonitorHandle {
Ok(store) => store.snapshot(),
Err(_) => MonitorState {
started_at: SystemTime::now(),
uptime: Duration::ZERO,
sessions: Vec::new(),
active: Vec::new(),
recent: Vec::new(),
Expand Down Expand Up @@ -869,6 +878,7 @@ impl MonitorStore {
);
MonitorState {
started_at: self.started_at,
uptime: self.started_instant.elapsed(),
sessions,
active,
recent: self.recent.iter().cloned().collect(),
Expand Down Expand Up @@ -1090,6 +1100,22 @@ mod tests {
assert_eq!(state.active[0].session_seq, Some(3));
}

#[test]
fn uptime_uses_the_service_monotonic_clock() {
let monitor = MonitorHandle::new(10);
{
let mut store = monitor.store.lock().unwrap();
store.started_at = SystemTime::now() + Duration::from_secs(3_600);
store.started_instant = Instant::now() - Duration::from_secs(42);
}

let state = monitor.snapshot();

assert!(state.started_at > SystemTime::now());
assert!(state.uptime >= Duration::from_secs(42));
assert!(state.uptime < Duration::from_secs(43));
}

#[test]
fn resolved_model_appends_to_incoming_alias() {
let monitor = MonitorHandle::new(10);
Expand Down
1 change: 1 addition & 0 deletions src/monitor/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ fn mock_state_for_tick(
let sessions = session_summaries(&active, &recent, &session_usage, output_buckets);
MonitorState {
started_at,
uptime: now.duration_since(started_at).unwrap_or_default(),
sessions,
active,
recent: recent.into_iter().collect(),
Expand Down
Loading