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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
238 changes: 226 additions & 12 deletions crates/probe/src/relay/claude/app.rs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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 (<Channel>)`), 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/")))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
&& 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.
Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

/// 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 {
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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, &registry.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() {
Expand All @@ -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, &registry(&[&chat])));
assert!(is_app_hosted_chat(&table, &chat, &registry(&[&chat])));

let nested = session(14, Some("interactive"));
assert!(!is_t3_chat(&table, &nested, &registry(&[&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, &registry(&[&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, &registry(&[&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, &registry(&[&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, &registry(&[&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, &registry(&[&run])));
}

#[test]
fn scratch_workspaces_are_the_apps_own_folders() {
assert!(is_scratch_workspace(
Expand Down
6 changes: 3 additions & 3 deletions crates/probe/src/relay/local/directory_trait.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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| {
Expand Down Expand Up @@ -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,
)
})
}
Expand Down
Loading