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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Notable changes to Agentaps are recorded here.

### Fixed

- Recover expired local Claude Code logins with browser sign-in and automatic session reconnection, while preserving remote-host guidance for SSH sessions.

- The website header shows the latest release and star count for `cachix/agentaps` in compact dark badges linking to the GitHub release and repository.

### Changed
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ For local Claude ACP sessions, Agentaps uses the Claude Code CLI at `CLAUDE_CODE
## Current limitations

- Web Connect shows the latest 100 messages per session and shortens long messages. It does not yet offer diff review or ACP form questions.
- URL based elicitation, ACP client file system and terminal methods, and built in authentication are not yet supported. Agents that require those client features may not work.
- Local Claude subscription sign-in is available from the session menu, or **Sign in to Claude** above the composer after a login expires. Complete sign-in in the browser; Agentaps reconnects the session so you can resend the request. SSH sessions still require sign-in on the remote host.
- URL based elicitation, ACP client file system and terminal methods, and general ACP authentication are not yet supported. Agents that require those client features may not work.
- Session data is stored at `$XDG_CONFIG_HOME/agentaps/config.json`, or `~/.config/agentaps/config.json` when `XDG_CONFIG_HOME` is unset, on Linux and macOS. Windows uses its roaming application data directory (`%APPDATA%/agentaps/config.json`). Existing configurations migrate automatically. Closing the app can interrupt an active turn.

## License
Expand Down
69 changes: 69 additions & 0 deletions src/app/render/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ impl Workspace {
}),
);
let location = project.display_path();
let local_claude = project.ssh_host.is_none()
&& known_agent(&agent.config.command) == Some(KnownAgent::Claude);
let can_sign_in = !agent.active_work && agent.login.is_none();
let can_reset = agent
.context
.is_some_and(|(used, size)| used > 0 && size > 0)
Expand Down Expand Up @@ -227,7 +230,21 @@ impl Workspace {
let reset_view = menu_view.clone();
let agent_view = menu_view.clone();
let archive_view = menu_view.clone();
let sign_in_view = menu_view.clone();
let menu = menu
.when(local_claude, |menu| {
menu.item(
PopupMenuItem::new("Sign in to Claude…")
.disabled(!can_sign_in)
.on_click(move |_, _, cx| {
sign_in_view.update(cx, |this, cx| {
if this.activate_session_pane(pane_id, agent_id, cx) {
this.sign_in_to_claude(project_index, agent_index, cx);
}
});
}),
)
})
.item(
PopupMenuItem::new("Rename session…").on_click(move |_, window, cx| {
rename_view.update(cx, |this, cx| {
Expand Down Expand Up @@ -451,6 +468,56 @@ impl Workspace {
),
),
);
if agent.auth_required || agent.login.is_some() {
let signing_in = agent.login.is_some();
let remote = project.ssh_host.is_some();
let login_url = agent.login.as_ref().and_then(|login| login.url.clone());
chat = chat.child(
div()
.w_full()
.px_4()
.py_2()
.flex()
.items_center()
.gap_2()
.child(
Button::new(("claude-sign-in", agent_id))
.label(if signing_in {
"Waiting for browser sign-in…"
} else {
"Sign in to Claude"
})
.disabled(signing_in || remote || agent.active_work)
.tooltip(if remote {
"Sign in on the SSH host with claude auth login"
} else {
"Sign in to your Claude subscription in the browser"
})
.on_click(self.pane_listener(cx, move |this, _, _, cx| {
this.sign_in_to_claude(project_index, agent_index, cx);
})),
)
.when_some(login_url, |row, url| {
row.child(
Button::new(("claude-open-login", agent_id))
.ghost()
.label("Open sign-in page")
.on_click(move |_, _, cx| cx.open_url(&url)),
)
})
.when(signing_in, |row| {
row.child(
Button::new(("claude-cancel-login", agent_id))
.ghost()
.label("Cancel")
.on_click(self.pane_listener(cx, move |this, _, _, cx| {
this.projects[project_index].agents[agent_index].login = None;
cx.notify();
})),
)
}),
);
}
let file_results = self.file_results(cx);
if self.file_mention_active(cx) {
let mut menu = div()
Expand Down Expand Up @@ -675,6 +742,8 @@ impl Workspace {
});
let send_tooltip = format!("Send ({})", self.shortcut_label(Shortcut::Send));
let can_send = !is_recording
&& !agent.auth_required
&& agent.login.is_none()
&& (!draft_images.is_empty()
|| !draft_files.is_empty()
|| !self
Expand Down
73 changes: 73 additions & 0 deletions src/app/sessions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,78 @@
use super::*;

impl Workspace {
pub(super) fn sign_in_to_claude(
&mut self,
project_index: usize,
agent_index: usize,
cx: &mut Context<Self>,
) {
let project = &mut self.projects[project_index];
let agent = &mut project.agents[agent_index];
if project.ssh_host.is_some() || agent.login.is_some() || agent.active_work {
return;
}
if known_agent(&agent.config.command) != Some(KnownAgent::Claude) {
return;
}
let executable = std::env::var_os("CLAUDE_CODE_EXECUTABLE")
.map(PathBuf::from)
.or_else(|| self.claude_executable.as_ref().map(PathBuf::from))
.or_else(|| crate::discovery::find_executable("claude"));
match executable
.ok_or_else(|| {
"Claude Code was not found. Set its executable in Settings > Agents.".into()
})
.and_then(|executable| crate::claude_auth::Login::start(&executable, &project.path))
{
Ok(login) => agent.login = Some(login),
Err(error) => agent.log(Role::System, error),
}
cx.notify();
}

fn poll_claude_login(&mut self, cx: &mut Context<Self>) -> bool {
let mut changed = false;
let mut reconnect = Vec::new();
for (project_index, project) in self.projects.iter_mut().enumerate() {
for (agent_index, agent) in project.agents.iter_mut().enumerate() {
let Some(login) = agent.login.as_mut() else {
continue;
};
let previous_url = login.url.clone();
let result = login.poll();
changed |= login.url != previous_url;
let Some(result) = result else { continue };
changed = true;
agent.login = None;
match result {
Ok(()) => {
agent.auth_required = false;
agent.active_work = false;
agent.awaiting_response = false;
agent.config.was_working = false;
agent.config.active_prompt = None;
agent.oauth_retry = None;
agent.status = Status::Connecting;
agent.protocol = None;
agent.elicitations.clear();
agent.permissions.clear();
agent.log(Role::System, "Signed in to Claude. Reconnecting this session; resend your request when it is ready.");
reconnect.push((project_index, agent_index));
}
Err(error) => agent.log(Role::System, error),
}
}
}
for (project_index, agent_index) in reconnect {
self.connect(project_index, agent_index);
}
if changed {
cx.notify();
}
changed
}

pub(super) fn open_rename_session(
&mut self,
agent_id: u64,
Expand Down Expand Up @@ -798,6 +870,7 @@ impl Workspace {
}
}
}
changed |= self.poll_claude_login(cx);
self.mark_displayed_agent_viewed();
for project in &mut self.projects {
for agent in &mut project.agents {
Expand Down
141 changes: 141 additions & 0 deletions src/claude_auth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
use std::{
io::{BufRead, BufReader},
path::Path,
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver},
thread,
time::{Duration, Instant},
};

/// Owns the browser login process so closing Agentaps also cancels sign-in.
pub(crate) struct Login {
child: Child,
urls: Receiver<String>,
started: Instant,
pub(crate) url: Option<String>,
}

fn login_url(line: &str) -> Option<String> {
line.split_whitespace().find_map(|word| {
let url = url::Url::parse(word).ok()?;
(url.scheme() == "https"
&& matches!(
url.host_str(),
Some("claude.ai" | "console.anthropic.com" | "platform.claude.com")
)
&& url.path().starts_with("/oauth/authorize"))
.then(|| url.to_string())
})
}

impl Login {
pub(crate) fn start(executable: &Path, cwd: &Path) -> Result<Self, String> {
let mut command = Command::new(executable);
command
.args(["auth", "login", "--claudeai"])
.current_dir(cwd)
.env_remove("ANTHROPIC_API_KEY")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
}
let mut child = command
.spawn()
.map_err(|error| format!("Could not start Claude sign-in: {error}"))?;
let (tx, urls) = mpsc::channel();
// Drain both streams, keeping only the browser URL. Never log credentials
// or persist the OAuth URL in conversation history.
let streams: Vec<Box<dyn std::io::Read + Send>> = vec![
Box::new(child.stdout.take().unwrap()),
Box::new(child.stderr.take().unwrap()),
];
for stream in streams {
let tx = tx.clone();
thread::spawn(move || {
for line in BufReader::new(stream).lines().map_while(Result::ok) {
if let Some(url) = login_url(&line) {
let _ = tx.send(url);
}
}
});
}
Ok(Self {
child,
urls,
started: Instant::now(),
url: None,
})
}

pub(crate) fn poll(&mut self) -> Option<Result<(), String>> {
for url in self.urls.try_iter() {
self.url = Some(url);
}
match self.child.try_wait() {
Ok(Some(status)) if status.success() => Some(Ok(())),
Ok(Some(_)) => Some(Err(
"Claude sign-in did not complete. Try again and finish the browser sign-in.".into(),
)),
Err(error) => Some(Err(format!("Could not check Claude sign-in: {error}"))),
Ok(None) if self.started.elapsed() >= Duration::from_secs(300) => Some(Err(
"Claude sign-in timed out. Try again and finish the browser sign-in within five minutes.".into(),
)),
Ok(None) => None,
}
}
}

impl Drop for Login {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn accepts_only_claude_oauth_authorization_urls() {
assert_eq!(
login_url("Open https://claude.ai/oauth/authorize?state=example"),
Some("https://claude.ai/oauth/authorize?state=example".into())
);
for line in [
"https://example.com/oauth/authorize",
"http://claude.ai/oauth/authorize",
"https://claude.ai.evil.example/oauth/authorize",
"https://claude.ai/docs",
"access_token=secret",
] {
assert_eq!(login_url(line), None);
}
}

#[cfg(unix)]
#[test]
fn background_login_reports_success_and_failure() {
use std::os::unix::fs::PermissionsExt;
let directory = tempfile::tempdir().unwrap();
let executable = directory.path().join("claude");
for (exit_code, success) in [(0, true), (1, false)] {
std::fs::write(&executable, format!("#!/bin/sh\nexit {exit_code}\n")).unwrap();
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap();
let mut login = Login::start(&executable, directory.path()).unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
let result = loop {
if let Some(result) = login.poll() {
break result;
}
assert!(Instant::now() < deadline);
thread::sleep(Duration::from_millis(10));
};
assert_eq!(result.is_ok(), success);
}
}
}
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod acp;
mod app;
mod appearance;
mod audio;
mod claude_auth;
mod cli;
mod config;
mod diff_view;
Expand Down
Loading
Loading