diff --git a/CHANGELOG.md b/CHANGELOG.md index 233b07018..e25012bdc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ All notable changes to Agent Relay will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [Unreleased - Patch] + +### Fixed + +- T3 Code chats running Claude now join Relay by themselves. The core took them for automation, so they never appeared in `relay agent list`. ## [15.0.0] - 2026-10-31 diff --git a/crates/probe/src/relay/claude/app.rs b/crates/probe/src/relay/claude/app.rs index 519862d9d..02d70ce50 100644 --- a/crates/probe/src/relay/claude/app.rs +++ b/crates/probe/src/relay/claude/app.rs @@ -1,5 +1,5 @@ -//! Claude chats an app hosts as a structured run: Paseo's top-level chats -//! and the chats in Claude's desktop app. The app runs each Code chat as a local +//! Claude chats an app hosts as a structured run: Paseo's top-level chats, +//! T3 Code's chats and the chats in Claude's desktop app. The app runs each Code chat as a local //! Claude Code process that publishes the same session record, inbox and //! socket as a terminal session, so delivery reaches it the same way. The //! app drives that process as a structured `stream-json` run with no @@ -8,7 +8,7 @@ use std::path::Path; -use super::TerminalSession; +use super::{Registry, TerminalSession}; use crate::relay::process::{ProcessTable, MAX_ANCESTRY}; /// The `entrypoint` the app records for its chats. @@ -22,6 +22,23 @@ pub fn is_app(executable: &Path) -> bool { && path.ends_with("/Claude.app/Contents/MacOS/Claude") } +/// T3 Code's main executable on any release channel (`T3 Code` or +/// `T3 Code ()`), installed for all users or for this one. +pub fn is_t3_app(executable: &Path) -> bool { + let Some(name) = executable.file_name().and_then(|name| name.to_str()) else { + return false; + }; + let channel_build = name + .strip_prefix("T3 Code (") + .and_then(|rest| rest.strip_suffix(')')) + .is_some_and(|channel| !channel.is_empty() && channel.chars().all(char::is_alphanumeric)); + let path = executable.to_string_lossy(); + (name == "T3 Code" || channel_build) + && (path.starts_with("/Applications/") + || (path.starts_with("/Users/") && path.contains("/Applications/"))) + && path.ends_with(&format!("/{name}.app/Contents/MacOS/{name}")) +} + /// Whether `directory` is a folder the app made for a chat with no project /// (`…/Claude/scratch-workspaces/…`), whose generated name says nothing /// about the chat. @@ -37,11 +54,48 @@ pub fn is_scratch_workspace(directory: &str) -> bool { pub fn is_app_chat( table: &dyn ProcessTable, session: &TerminalSession, - sessions: &[TerminalSession], + registry: &Registry, +) -> bool { + session.entrypoint.as_deref() == Some(ENTRYPOINT) && hosted_by(table, session, registry, is_app) +} + +/// Whether a live registry entry is a chat a person runs in T3 Code, which +/// records its chats as `sdk-ts` like any SDK run, so only the process tree +/// tells them apart. A Claude session a chat starts stays automation, and so +/// does `claude -p` in T3 Code's terminal, which can also record `interactive`. +pub fn is_t3_chat( + table: &dyn ProcessTable, + session: &TerminalSession, + registry: &Registry, +) -> bool { + is_structured_chat_process(table, session.pid) && hosted_by(table, session, registry, is_t3_app) +} + +/// Whether `pid` runs the way an app drives a chat: a `stream-json` pipe with +/// no terminal and no `-p`/`--print`. +fn is_structured_chat_process(table: &dyn ProcessTable, pid: i32) -> bool { + let arguments = table.arguments(pid); + let printing = arguments + .iter() + .any(|arg| arg == "-p" || arg == "--print" || arg.starts_with("--print=")); + let structured = arguments.iter().enumerate().any(|(index, argument)| { + argument == "--input-format=stream-json" + || (argument == "--input-format" + && arguments.get(index + 1).map(String::as_str) == Some("stream-json")) + }); + structured && !printing && !table.has_terminal(pid) +} + +/// Whether an interactive registry entry was started by this user's `host` +/// executable, with no other Claude Code session between them, not even one +/// whose registry file cannot be read. +fn hosted_by( + table: &dyn ProcessTable, + session: &TerminalSession, + registry: &Registry, + host: fn(&Path) -> bool, ) -> bool { - if session.entrypoint.as_deref() != Some(ENTRYPOINT) - || session.kind.as_deref() != Some("interactive") - { + if session.kind.as_deref() != Some("interactive") { return false; } let Some(uid) = table.process(session.pid).map(|process| process.uid) else { @@ -53,10 +107,12 @@ pub fn is_app_chat( return false; }; if current != session.pid { - if table.executable(current).is_some_and(|exe| is_app(&exe)) { + if table.executable(current).is_some_and(|exe| host(&exe)) { return true; } - if sessions.iter().any(|other| other.pid == current) { + if registry.sessions.iter().any(|other| other.pid == current) + || registry.unreadable.iter().any(|file| file.pid == current) + { return false; } } @@ -86,18 +142,23 @@ pub fn is_paseo_chat( } /// Whether a live registry entry is a person's chat that an app hosts as a -/// structured run: a top-level Paseo chat or a Claude desktop app chat. +/// structured run: a top-level Paseo chat, a T3 Code chat or a Claude desktop +/// app chat. pub fn is_app_hosted_chat( table: &dyn ProcessTable, session: &TerminalSession, - sessions: &[TerminalSession], + registry: &Registry, ) -> bool { - is_paseo_chat(table, session, sessions) || is_app_chat(table, session, sessions) + is_paseo_chat(table, session, ®istry.sessions) + || is_t3_chat(table, session, registry) + || is_app_chat(table, session, registry) } #[cfg(test)] mod tests { use super::*; + use crate::relay::process::{FakeProcess, FakeProcesses}; + use crate::relay::sessions::UnreadableSessionFile; #[test] fn only_the_claude_app_bundle_is_the_app() { @@ -116,6 +177,159 @@ mod tests { } } + #[test] + fn only_the_t3_code_bundles_are_t3_code() { + for app in [ + "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)", + "/Applications/T3 Code.app/Contents/MacOS/T3 Code", + "/Applications/T3 Code (Beta).app/Contents/MacOS/T3 Code (Beta)", + "/Applications/T3 Code (Nightly).app/Contents/MacOS/T3 Code (Nightly)", + "/Users/me/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)", + ] { + assert!(is_t3_app(Path::new(app)), "{app}"); + } + for other in [ + "/Applications/T3 Code (Alpha).app/Contents/Resources/resource-monitor/t3-resource-monitor", + "/Applications/T3 Code (Alpha).app/Contents/Frameworks/T3 Code (Alpha) Helper (Renderer).app/Contents/MacOS/T3 Code (Alpha) Helper (Renderer)", + "/Applications/T3 Code (Beta).app/Contents/MacOS/T3 Code", + "/Applications/T3 Code ().app/Contents/MacOS/T3 Code ()", + "/tmp/T3 Code.app/Contents/MacOS/T3 Code", + "/Applications/Claude.app/Contents/MacOS/Claude", + ] { + assert!(!is_t3_app(Path::new(other)), "{other}"); + } + } + + const T3: &str = "/Applications/T3 Code (Alpha).app/Contents/MacOS/T3 Code (Alpha)"; + + fn process(parent: i32, executable: Option<&str>) -> FakeProcess { + FakeProcess { + parent, + uid: 501, + executable: executable.map(Into::into), + ..FakeProcess::default() + } + } + + fn chat_process(parent: i32) -> FakeProcess { + FakeProcess { + arguments: vec![ + "claude".into(), + "--output-format".into(), + "stream-json".into(), + "--input-format".into(), + "stream-json".into(), + ], + ..process(parent, Some("/usr/local/bin/claude")) + } + } + + fn session(pid: i32, kind: Option<&str>) -> TerminalSession { + TerminalSession { + session_id: "7f2a4cab-5d9e-4e3a-9b8c-3c4d5e6f7a01".into(), + pid, + socket_path: "/s".into(), + peer_protocol: 1, + proc_start: None, + cwd: None, + name: None, + status: None, + entrypoint: Some("sdk-ts".into()), + kind: kind.map(Into::into), + updated_at_ms: None, + registry: None, + } + } + + fn registry(sessions: &[&TerminalSession]) -> Registry { + Registry { + sessions: sessions.iter().map(|&session| session.clone()).collect(), + unreadable: Vec::new(), + } + } + + #[test] + fn a_t3_code_chat_is_a_persons_chat_but_what_it_starts_is_not() { + let table = FakeProcesses::default(); + table.insert(10, process(1, Some(T3))); + table.insert(11, process(10, Some(T3))); + table.insert(12, chat_process(11)); + table.insert(13, process(12, Some("/bin/zsh"))); + table.insert(14, chat_process(13)); + + let chat = session(12, Some("interactive")); + assert!(is_t3_chat(&table, &chat, ®istry(&[&chat]))); + assert!(is_app_hosted_chat(&table, &chat, ®istry(&[&chat]))); + + let nested = session(14, Some("interactive")); + assert!(!is_t3_chat(&table, &nested, ®istry(&[&chat, &nested]))); + + // The parent chat's record cannot be read: its pid still stops the walk. + let unreadable = Registry { + sessions: vec![nested.clone()], + unreadable: vec![UnreadableSessionFile { + path: "/sessions/12.json".into(), + pid: 12, + error: "expected value".into(), + }], + }; + assert!(!is_t3_chat(&table, &nested, &unreadable)); + + let run = session(12, Some("sdk")); + assert!(!is_t3_chat(&table, &run, ®istry(&[&run]))); + } + + #[test] + fn claude_print_in_t3_codes_terminal_stays_automation() { + let table = FakeProcesses::default(); + table.insert(30, process(1, Some(T3))); + table.insert(31, process(30, Some("/bin/zsh"))); + table.insert( + 32, + FakeProcess { + arguments: vec!["claude".into(), "-p".into(), "hello".into()], + has_terminal: true, + ..process(31, Some("/usr/local/bin/claude")) + }, + ); + let run = session(32, Some("interactive")); + assert!(!is_t3_chat(&table, &run, ®istry(&[&run]))); + + // A structured pipe in that terminal is a script, not the app's chat. + table.insert( + 33, + FakeProcess { + has_terminal: true, + ..chat_process(31) + }, + ); + let piped = session(33, Some("interactive")); + assert!(!is_t3_chat(&table, &piped, ®istry(&[&piped]))); + + table.insert( + 34, + FakeProcess { + arguments: vec![ + "claude".into(), + "-p".into(), + "--input-format=stream-json".into(), + ], + ..process(30, Some("/usr/local/bin/claude")) + }, + ); + let printing = session(34, Some("interactive")); + assert!(!is_t3_chat(&table, &printing, ®istry(&[&printing]))); + } + + #[test] + fn a_sdk_run_outside_t3_code_stays_automation() { + let table = FakeProcesses::default(); + table.insert(20, process(1, Some("/usr/local/bin/node"))); + table.insert(21, chat_process(20)); + let run = session(21, Some("interactive")); + assert!(!is_t3_chat(&table, &run, ®istry(&[&run]))); + } + #[test] fn scratch_workspaces_are_the_apps_own_folders() { assert!(is_scratch_workspace( diff --git a/crates/probe/src/relay/local/directory_trait.rs b/crates/probe/src/relay/local/directory_trait.rs index 9e0527bd1..a82c2376c 100644 --- a/crates/probe/src/relay/local/directory_trait.rs +++ b/crates/probe/src/relay/local/directory_trait.rs @@ -349,7 +349,7 @@ impl LocalSessions { /// `auto_activates`, so the SessionStart hook never promises a join /// activation would not make. fn activation_check(&self) -> impl Fn(&DiscoveredSession) -> bool + '_ { - let claude_sessions = claude::registry(&self.config.claude, &*self.table).sessions; + let claude_registry = claude::registry(&self.config.claude, &*self.table); let grok_reachable = move |session_id: &str| self.grok_open_reachable(session_id); let automated = std::cell::OnceCell::new(); let started_by_automation = move |session_id: &str| { @@ -396,12 +396,12 @@ impl LocalSessions { &*self.table, self.config.uid, &terminal, - &claude_sessions, + &claude_registry.sessions, ) || claude::is_app_hosted_chat( &*self.table, &terminal, - &claude_sessions, + &claude_registry, ) }) }