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
45 changes: 43 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ CodexMonitor is a Tauri app for orchestrating multiple Codex agents across local
- Worktree and clone agents for isolated work; worktrees live under the app data directory (legacy `.codex-worktrees` supported).
- Thread management: pin/rename/archive/copy, per-thread drafts, and stop/interrupt in-flight turns.
- Optional remote backend (daemon) mode for running Codex on another machine.
- Remote setup helpers for self-hosted connectivity (Tailscale detection/host bootstrap for TCP mode).
- Remote setup helpers for self-hosted connectivity (Tailscale host bootstrap for TCP mode and Cloudflare quick tunnel for WSS mode).

### Composer & Agent Controls

Expand Down Expand Up @@ -101,6 +101,44 @@ Notes:
- The desktop daemon must stay running while iOS is connected.
- If the test fails, confirm both devices are online in Tailscale and that host/token match desktop settings.

### iOS + Cloudflare Tunnel Setup (WSS)

Use this when connecting over public internet without requiring Tailscale on iOS.

Recommended (desktop one-click):

1. On desktop CodexMonitor, open `Settings > Server`.
2. Click `One-click WSS setup`.
3. CodexMonitor will:
- generate/save a remote password if missing,
- start the desktop daemon with TCP + WS listeners,
- start a Cloudflare quick tunnel,
- auto-fill the active remote host with the discovered `wss://...` URL.
4. On iOS CodexMonitor, open `Settings > Server`.
5. Enter the same password/token shown on desktop.
6. Tap `Connect & test`.

Manual fallback:

1. Start daemon with both TCP and WebSocket listeners:

```bash
cd src-tauri
cargo run --bin codex_monitor_daemon -- \
--listen 127.0.0.1:4732 \
--ws-listen 127.0.0.1:4733 \
--token "<same-token-from-settings>"
```

2. Run `cloudflared tunnel --url http://127.0.0.1:4733`.
3. Use the generated `https://...` endpoint as `wss://...` in iOS settings.

Notes:

- Keep desktop daemon/tunnel processes running while iOS is connected.
- Prefer Cloudflare Access / origin restrictions when exposing this endpoint.
- Rotate the remote token if endpoint scope changes.

### Headless Daemon Management (No Desktop UI)

Use the standalone daemon control CLI when you want iOS remote mode without keeping the desktop app open.
Expand All @@ -126,6 +164,9 @@ Examples:

# Print equivalent daemon start command
./target/debug/codex_monitor_daemonctl command-preview

# Start daemon with optional WebSocket listener (for WSS reverse proxies/tunnels)
./target/debug/codex_monitor_daemon --listen 127.0.0.1:4732 --ws-listen 127.0.0.1:4733 --token "$TOKEN"
```

Useful overrides:
Expand Down Expand Up @@ -313,4 +354,4 @@ Frontend calls live in `src/services/tauri.ts` and map to commands in `src-tauri
- Git/GitHub: `get_git_status`, `list_git_roots`, `get_git_diffs`, `get_git_log`, `get_git_commit_diff`, `get_git_remote`, `stage_git_file`, `stage_git_all`, `unstage_git_file`, `revert_git_file`, `revert_git_all`, `commit_git`, `push_git`, `pull_git`, `fetch_git`, `sync_git`, `list_git_branches`, `checkout_git_branch`, `create_git_branch`, `get_github_issues`, `get_github_pull_requests`, `get_github_pull_request_diff`, `get_github_pull_request_comments`.
- Prompts: `prompts_list`, `prompts_create`, `prompts_update`, `prompts_delete`, `prompts_move`, `prompts_workspace_dir`, `prompts_global_dir`.
- Terminal/dictation/notifications/usage: `terminal_open`, `terminal_write`, `terminal_resize`, `terminal_close`, `dictation_model_status`, `dictation_download_model`, `dictation_cancel_download`, `dictation_remove_model`, `dictation_request_permission`, `dictation_start`, `dictation_stop`, `dictation_cancel`, `send_notification_fallback`, `is_macos_debug_build`, `local_usage_snapshot`.
- Remote backend helpers: `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`.
- Remote backend helpers: `tailscale_status`, `tailscale_daemon_command_preview`, `tailscale_daemon_start`, `tailscale_daemon_stop`, `tailscale_daemon_status`, `cloudflare_tunnel_start`, `cloudflare_tunnel_stop`, `cloudflare_tunnel_status`, `cloudflare_tunnel_install`.
88 changes: 73 additions & 15 deletions src-tauri/src/bin/codex_monitor_daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ use ignore::WalkBuilder;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, mpsc, Mutex, Semaphore};
use tokio_tungstenite::accept_async;

use backend::app_server::{spawn_workspace_session, WorkspaceSession};
use backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput};
Expand All @@ -93,6 +94,7 @@ use types::{
use workspace_settings::apply_workspace_settings_update;

const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:4732";
const DEFAULT_WS_LISTEN_ADDR: &str = "127.0.0.1:4733";
const MAX_IN_FLIGHT_RPC_PER_CONNECTION: usize = 32;
const DAEMON_NAME: &str = "codex-monitor-daemon";

Expand Down Expand Up @@ -144,6 +146,7 @@ impl EventSink for DaemonEventSink {

struct DaemonConfig {
listen: SocketAddr,
ws_listen: Option<SocketAddr>,
token: Option<String>,
data_dir: PathBuf,
}
Expand Down Expand Up @@ -757,8 +760,7 @@ impl DaemonState {
limit: Option<u32>,
sort_key: Option<String>,
) -> Result<Value, String> {
codex_core::list_threads_core(&self.sessions, workspace_id, cursor, limit, sort_key)
.await
codex_core::list_threads_core(&self.sessions, workspace_id, cursor, limit, sort_key).await
}

async fn list_mcp_server_status(
Expand Down Expand Up @@ -1487,15 +1489,16 @@ fn default_data_dir() -> PathBuf {
fn usage() -> String {
format!(
"\
USAGE:\n codex-monitor-daemon [--listen <addr>] [--data-dir <path>] [--token <token> | --insecure-no-auth]\n\n\
OPTIONS:\n --listen <addr> Bind address (default: {DEFAULT_LISTEN_ADDR})\n --data-dir <path> Data dir holding workspaces.json/settings.json\n --token <token> Shared token required by TCP clients\n --insecure-no-auth Disable TCP auth (dev only)\n -h, --help Show this help\n"
USAGE:\n codex-monitor-daemon [--listen <addr>] [--ws-listen <addr>] [--data-dir <path>] [--token <token> | --insecure-no-auth]\n\n\
OPTIONS:\n --listen <addr> Bind address for TCP JSON-RPC (default: {DEFAULT_LISTEN_ADDR})\n --ws-listen <addr> Optional bind address for WebSocket JSON-RPC (example: {DEFAULT_WS_LISTEN_ADDR})\n --data-dir <path> Data dir holding workspaces.json/settings.json\n --token <token> Shared token required by clients\n --insecure-no-auth Disable auth (dev only)\n -h, --help Show this help\n"
)
}

fn parse_args() -> Result<DaemonConfig, String> {
let mut listen = DEFAULT_LISTEN_ADDR
.parse::<SocketAddr>()
.map_err(|err| err.to_string())?;
let mut ws_listen: Option<SocketAddr> = None;
let mut token = env::var("CODEX_MONITOR_DAEMON_TOKEN")
.ok()
.map(|value| value.trim().to_string())
Expand All @@ -1514,6 +1517,10 @@ fn parse_args() -> Result<DaemonConfig, String> {
let value = args.next().ok_or("--listen requires a value")?;
listen = value.parse::<SocketAddr>().map_err(|err| err.to_string())?;
}
"--ws-listen" => {
let value = args.next().ok_or("--ws-listen requires a value")?;
ws_listen = Some(value.parse::<SocketAddr>().map_err(|err| err.to_string())?);
}
"--token" => {
let value = args.next().ok_or("--token requires a value")?;
let trimmed = value.trim();
Expand Down Expand Up @@ -1547,6 +1554,7 @@ fn parse_args() -> Result<DaemonConfig, String> {

Ok(DaemonConfig {
listen,
ws_listen,
token,
data_dir: data_dir.unwrap_or_else(default_data_dir),
})
Expand Down Expand Up @@ -1926,28 +1934,78 @@ fn main() {
std::process::exit(2);
}
};
let ws_listener = if let Some(ws_addr) = config.ws_listen {
match TcpListener::bind(ws_addr).await {
Ok(listener) => Some(listener),
Err(err) => {
eprintln!("failed to bind websocket listener on {}: {err}", ws_addr);
std::process::exit(2);
}
}
} else {
None
};
eprintln!(
"codex-monitor-daemon listening on {} (data dir: {})",
"codex-monitor-daemon listening on {}{} (data dir: {})",
config.listen,
config
.ws_listen
.map(|addr| format!(", ws {}", addr))
.unwrap_or_default(),
state
.storage_path
.parent()
.unwrap_or(&state.storage_path)
.display()
);

loop {
match listener.accept().await {
Ok((socket, _addr)) => {
let config = Arc::clone(&config);
let state = Arc::clone(&state);
let events = events_tx.clone();
tokio::spawn(async move {
transport::handle_client(socket, config, state, events).await;
});
let config_for_tcp = Arc::clone(&config);
let state_for_tcp = Arc::clone(&state);
let events_for_tcp = events_tx.clone();
let tcp_task = tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((socket, _addr)) => {
let config = Arc::clone(&config_for_tcp);
let state = Arc::clone(&state_for_tcp);
let events = events_for_tcp.clone();
tokio::spawn(async move {
transport::handle_client(socket, config, state, events).await;
});
}
Err(_) => continue,
}
Err(_) => continue,
}
});

if let Some(ws_listener) = ws_listener {
let config_for_ws = Arc::clone(&config);
let state_for_ws = Arc::clone(&state);
let events_for_ws = events_tx.clone();
let ws_task = tokio::spawn(async move {
loop {
match ws_listener.accept().await {
Ok((socket, _addr)) => {
let config = Arc::clone(&config_for_ws);
let state = Arc::clone(&state_for_ws);
let events = events_for_ws.clone();
tokio::spawn(async move {
if let Ok(ws_stream) = accept_async(socket).await {
transport::handle_websocket_client(
ws_stream, config, state, events,
)
.await;
}
});
}
Err(_) => continue,
}
}
});

let _ = futures_util::future::join(tcp_task, ws_task).await;
} else {
let _ = tcp_task.await;
}
});
}
Loading
Loading