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
150 changes: 124 additions & 26 deletions crates/tracedecay-agent-hosts/src/hooks/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
//!
//! Claude and Codex share the common hook JSON shape.

use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};

use serde_json::Value;

use super::post_tool_use::is_post_tool_use_failure_event;
use super::steering::{cursor_index_signals_for_root, index_status_line};
use super::steering::index_status_line;
use super::tool_hints::{HintAgent, ToolHintInput, decide_hint};
use super::{
additional_context_json, compact_daemon_args, event_project_root,
Expand Down Expand Up @@ -183,6 +185,20 @@ const CLAUDE_SUBAGENT_START_CONTEXT: &str = "graph before grep; tools may be def
ToolSearch select:tracedecay_context,tracedecay_grep,tracedecay_callers; route literal->grep, \
symbol->search, concept->context";

/// The outer Claude plugin guard is five seconds. Keep daemon-backed context
/// lookup and receipt delivery below two seconds together so a saturated but
/// connectable daemon cannot delay child startup.
const CLAUDE_SUBAGENT_START_BUDGET: Duration = Duration::from_millis(1_500);
const CLAUDE_SUBAGENT_OUTPUT_BUDGET: Duration = Duration::from_millis(250);

#[derive(Debug, PartialEq, Eq)]
enum ClaudeSubagentStartContextOutcome {
Ready(String),
NoProject,
Unavailable,
TimedOut,
}

/// Claude Code `SubagentStart` hook handler.
///
/// Mirrors [`hook_codex_subagent_start`](super::codex::hook_codex_subagent_start)
Expand All @@ -192,30 +208,70 @@ symbol->search, concept->context";
/// nothing to steer toward). Analytics are fire-and-forget like `SessionStart`.
pub async fn hook_claude_subagent_start() -> i32 {
let event = read_hook_event!();
let started = Instant::now();
let parsed = serde_json::from_str::<Value>(&event).unwrap_or(Value::Null);
let root = event_project_root_with_identity(&parsed).await;
let _hook_telemetry = record_hook_invoked_parsed(
// Subagent startup must not open the global registry merely to discover a
// route. Resolve a local workspace boundary and let the one bounded status
// request map a registered global-only alias when one exists.
let root = claude_subagent_project_root(&parsed);
let hook_telemetry = record_hook_invoked_parsed(
root.as_deref(),
HintAgent::Claude,
"SubagentStart",
&event,
&parsed,
);
let output = if let Some(context) = claude_subagent_start_context(root.as_deref()).await {
additional_context_json("SubagentStart", &context)
} else {
serde_json::json!({}).to_string()
let remaining = CLAUDE_SUBAGENT_START_BUDGET.saturating_sub(started.elapsed());
let outcome = match root.as_deref() {
Some(_) if remaining.is_zero() => ClaudeSubagentStartContextOutcome::TimedOut,
Some(root) => {
bounded_claude_subagent_start_context(
super::steering::cursor_index_signals_for_root_result(root),
remaining,
)
.await
}
None => ClaudeSubagentStartContextOutcome::NoProject,
};
if !super::write_hook_output(
root.as_deref(),
tracedecay_hooks::HookHostV1::ClaudeCode,
&event,
&output,
Some(&_hook_telemetry),
let output = match outcome {
ClaudeSubagentStartContextOutcome::Ready(context) => {
additional_context_json("SubagentStart", &context)
}
ClaudeSubagentStartContextOutcome::NoProject => serde_json::json!({}).to_string(),
ClaudeSubagentStartContextOutcome::Unavailable => {
eprintln!(
"[tracedecay] Claude SubagentStart failed open: \
stage=daemon_status outcome=unavailable elapsed_ms={}",
started.elapsed().as_millis()
);
serde_json::json!({}).to_string()
}
ClaudeSubagentStartContextOutcome::TimedOut => {
eprintln!(
"[tracedecay] Claude SubagentStart failed open: \
stage=daemon_status outcome=timeout elapsed_ms={}",
started.elapsed().as_millis()
);
serde_json::json!({}).to_string()
}
};
let delivered = tokio::time::timeout(
CLAUDE_SUBAGENT_OUTPUT_BUDGET,
super::write_hook_output(
Comment on lines +258 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move blocking delivery work outside the Tokio timeout

When the receipt spool or stdout is on a slow or stalled filesystem/pipe, this timeout cannot enforce the advertised 250 ms output budget: write_hook_output resolves the layout, opens the spool, writes and flushes stdout, and durably appends the receipt synchronously before reaching its first .await. A single poll therefore blocks the runtime thread past the timer deadline, potentially leaving Claude waiting until the manifest's five-second outer timeout; isolate the blocking delivery operations instead of wrapping the existing future.

Useful? React with 👍 / 👎.

root.as_deref(),
tracedecay_hooks::HookHostV1::ClaudeCode,
&event,
&output,
Some(&hook_telemetry),
),
)
.await
{
return 1;
.await;
if !matches!(delivered, Ok(true)) {
eprintln!(
"[tracedecay] Claude SubagentStart failed open: \
stage=output_delivery outcome=unavailable elapsed_ms={}",
started.elapsed().as_millis()
);
}
0
}
Expand Down Expand Up @@ -257,16 +313,36 @@ pub async fn hook_claude_post_compact() -> i32 {
0
}

/// Builds the compact `SubagentStart` `additionalContext` for a Claude event, or
/// `None` when root detection fails (no project to steer toward). The status
/// line is resolved the same registry-aware way as `SessionStart` so a
/// global-store-only project still steers correctly.
async fn claude_subagent_start_context(root: Option<&Path>) -> Option<String> {
let root = root?;
let (staleness, _) = cursor_index_signals_for_root(root).await;
let mut context = index_status_line(true, staleness.as_deref());
context.push_str(CLAUDE_SUBAGENT_START_CONTEXT);
Some(context)
fn claude_subagent_project_root(parsed: &Value) -> Option<PathBuf> {
let cwd = super::event_cwd_from_parsed(parsed)?;
if let Some(root) = super::nearest_project_like_root(&cwd) {
return Some(root);
Comment on lines +318 to +319

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject ambient roots before returning marker-based roots

When $HOME is a dotfiles repository or contains a marker such as package.json, and Claude starts in a non-project descendant, nearest_project_like_root returns the entire home directory here before the ambient-root check below can run. The hook then unnecessarily scopes analytics, daemon status, and output delivery to a root that the newly added daemon routing guard rejects, potentially consuming the full status budget on every subagent start; apply the canonical is_ambient_project_root authority to this path as well.

AGENTS.md reference: AGENTS.md:L82-L89

Useful? React with 👍 / 👎.

}
let root = crate::config::discover_project_root(&cwd)?;
let is_ambient_root = root.parent().is_none()
|| ["HOME", "USERPROFILE"]
.iter()
.filter_map(std::env::var_os)
.any(|home| Path::new(&home) == root);
(!is_ambient_root).then_some(root)
}

async fn bounded_claude_subagent_start_context<F>(
status: F,
budget: Duration,
) -> ClaudeSubagentStartContextOutcome
where
F: Future<Output = crate::errors::Result<(Option<String>, Option<u64>)>>,
{
match tokio::time::timeout(budget, status).await {
Ok(Ok((staleness, _))) => {
let mut context = index_status_line(true, staleness.as_deref());
context.push_str(CLAUDE_SUBAGENT_START_CONTEXT);
ClaudeSubagentStartContextOutcome::Ready(context)
}
Ok(Err(_)) => ClaudeSubagentStartContextOutcome::Unavailable,
Err(_) => ClaudeSubagentStartContextOutcome::TimedOut,
}
}

/// Claude Code `PostToolUse` / `PostToolUseFailure` hook handler.
Expand Down Expand Up @@ -777,5 +853,27 @@ mod tests {
assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("literal->grep"));
assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("symbol->search"));
assert!(CLAUDE_SUBAGENT_START_CONTEXT.contains("concept->context"));
assert!(
CLAUDE_SUBAGENT_START_BUDGET + CLAUDE_SUBAGENT_OUTPUT_BUDGET < Duration::from_secs(2)
);
}

#[tokio::test]
async fn subagent_start_context_times_out_fail_open() {
let status = std::future::pending::<crate::errors::Result<(Option<String>, Option<u64>)>>();
let outcome =
bounded_claude_subagent_start_context(status, Duration::from_millis(10)).await;

assert_eq!(outcome, ClaudeSubagentStartContextOutcome::TimedOut);
}

#[tokio::test]
async fn subagent_start_context_treats_daemon_errors_as_unavailable() {
let status = std::future::ready(Err(crate::errors::TraceDecayError::Config {
message: "daemon unavailable".to_string(),
}));
let outcome = bounded_claude_subagent_start_context(status, Duration::from_secs(1)).await;

assert_eq!(outcome, ClaudeSubagentStartContextOutcome::Unavailable);
}
}
16 changes: 8 additions & 8 deletions crates/tracedecay-agent-hosts/src/hooks/steering.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,17 +241,17 @@ pub fn cursor_staleness_hint(age_secs: i64) -> String {
}
}

/// Opens the index once and reads both session-steering signals.
pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option<String>, Option<u64>) {
let Ok(status) = super::daemon_tool_json(
/// Result-preserving status lookup for latency-sensitive hooks that must
/// distinguish an unavailable daemon from a healthy index with no signals.
pub(super) async fn cursor_index_signals_for_root_result(
root: &Path,
) -> crate::errors::Result<(Option<String>, Option<u64>)> {
let status = super::daemon_tool_json(
Some(root),
"tracedecay_status",
serde_json::json!({ "format": "json" }),
)
.await
else {
return (None, None);
};
.await?;
let last = status
.get("last_updated")
.and_then(serde_json::Value::as_i64)
Expand All @@ -260,7 +260,7 @@ pub(super) async fn cursor_index_signals_for_root(root: &Path) -> (Option<String
let tokens_saved = status
.get("tokens_saved")
.and_then(serde_json::Value::as_u64);
(staleness, tokens_saved)
Ok((staleness, tokens_saved))
}

#[cfg(test)]
Expand Down
41 changes: 15 additions & 26 deletions crates/tracedecay-cli/src/tool_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! JSON, and exit without dispatching the tool. Otherwise it is forwarded as
//! the tool's boolean argument.
//! - `--project <path>` — project root to target. Defaults to the nearest
//! initialised project walking up from cwd (falling back to cwd). We use
//! initialised project walking up from cwd. We use
//! `--project` (not `-p`) because several MCP tools have a `path` argument
//! that filters files within the project.
//! - `--args <json|file|->` — escape hatch. Treats the value as the entire
Expand All @@ -41,7 +41,7 @@

use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use serde_json::Value;
Expand Down Expand Up @@ -447,24 +447,21 @@ impl DaemonToolDispatch {
}

fn project_scoped(explicit_project: Option<String>, tool_name: &str) -> Self {
// Same resolution as `tracedecay sync`/`status`/`serve`: an explicit
// --project wins; otherwise walk up from cwd to the nearest initialised
// project so the command works from subdirectories.
// An explicit --project wins. Otherwise only route to the nearest
// initialised ancestor. Keeping an unscoped invocation projectless is
// important: falling back to cwd can turn a broad directory such as
// the user profile into an accidental project handshake.
let explicitly_targeted = explicit_project.is_some();
let project_path = tracedecay::config::resolve_path_with_discovery(explicit_project);
// Never treat the filesystem root as a discovered project fallback.
// Callers that need a project must pass --project; otherwise the daemon
// serves the profile-scoped projectless route.
if !explicitly_targeted && is_filesystem_root(&project_path) {
return Self {
project_path: None,
allow_init: false,
};
}
let project_path = match explicit_project {
Some(path) => Some(tracedecay::config::resolve_path(Some(path))),
None => std::env::current_dir()
.ok()
.and_then(|cwd| implicit_tool_project_path(&cwd)),
};
let allow_init = explicitly_targeted && FIRST_TOUCH_STORE_TOOLS.contains(&tool_name);

Self {
project_path: Some(project_path),
project_path,
allow_init,
}
}
Expand Down Expand Up @@ -497,16 +494,8 @@ fn requests_profile_authority(tool_args: &Value) -> bool {
)
}

fn is_filesystem_root(path: &Path) -> bool {
let mut saw_root = false;
for component in path.components() {
match component {
Component::RootDir | Component::Prefix(_) => saw_root = true,
Component::CurDir => {}
Component::ParentDir | Component::Normal(_) => return false,
}
}
saw_root
fn implicit_tool_project_path(cwd: &Path) -> Option<PathBuf> {
tracedecay::config::discover_project_root(cwd)
}

fn map_tool_deadline_error(tool_name: &str, error: TraceDecayError) -> TraceDecayError {
Expand Down
11 changes: 7 additions & 4 deletions crates/tracedecay-cli/src/tool_command/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ fn explicit_project_lcm_dispatch_allows_first_touch_init() {
);

assert!(dispatch.allow_init);
assert_eq!(dispatch.project_path, Some(PathBuf::from("/tmp/project")));
}

#[test]
Expand Down Expand Up @@ -321,10 +322,12 @@ fn user_memory_scope_dispatch_is_projectless() {
}

#[test]
fn filesystem_root_path_is_never_accepted_as_discovered_project() {
assert!(is_filesystem_root(std::path::Path::new("/")));
assert!(!is_filesystem_root(std::path::Path::new("/tmp/project")));
assert!(!is_filesystem_root(std::path::Path::new(".")));
fn implicit_tool_dispatch_stays_projectless_without_initialized_ancestor() {
let root = tempfile::tempdir().expect("projectless tool fixture");
let nested = root.path().join("nested");
std::fs::create_dir_all(&nested).expect("nested directory");

assert_eq!(implicit_tool_project_path(&nested), None);
}

// --- Validation gate and corrective-error contract ---
Expand Down
21 changes: 18 additions & 3 deletions crates/tracedecay-runtime-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,10 @@ pub fn discover_project_root(start: &Path) -> Option<PathBuf> {
let at_worktree_root = worktree_root
.as_ref()
.is_some_and(|root| paths_same(&dir, root));
if has_project_database(&dir)
let initialized = has_project_database(&dir)
|| crate::storage::has_path_local_profile_store(&dir)
|| (at_worktree_root && crate::storage::has_repository_identity_marker(&dir))
{
|| (at_worktree_root && crate::storage::has_repository_identity_marker(&dir));
if initialized && !is_ambient_project_root(&dir) {
return Some(dir);
}
if at_worktree_root {
Expand All @@ -157,6 +157,21 @@ pub fn discover_project_root(start: &Path) -> Option<PathBuf> {
}
}

/// Returns whether a path is too broad to be an implicit code-project root.
///
/// Filesystem roots and the current user profile commonly contain many
/// repositories. Treating either as an implicit project can turn MCP startup
/// freshness work into a full-machine or full-home traversal.
pub fn is_ambient_project_root(path: &Path) -> bool {
let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
canonical.parent().is_none()
|| ["HOME", "USERPROFILE"]
.iter()
.filter_map(std::env::var_os)
.map(PathBuf::from)
.map(|home| std::fs::canonicalize(&home).unwrap_or(home))
.any(|home| home == canonical)
}
fn paths_same(left: &Path, right: &Path) -> bool {
let left = std::fs::canonicalize(left).unwrap_or_else(|_| left.to_path_buf());
let right = std::fs::canonicalize(right).unwrap_or_else(|_| right.to_path_buf());
Expand Down
5 changes: 2 additions & 3 deletions crates/tracedecay/src/bin/tracedecay-search-eval-direct.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,11 +386,10 @@ mod tests {
#[test]
fn default_validation_uses_byte_pinned_activation_workload() {
let summary = validate_requested_workload(
&PathBuf::from(env!("CARGO_MANIFEST_DIR"))
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("workspace root above crates/tracedecay")
.to_owned(),
.expect("workspace root above crates/tracedecay"),
None,
)
.expect("checked-in activation workload validates");
Expand Down
2 changes: 1 addition & 1 deletion crates/tracedecay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pub const CONFIG_FILENAME: &str = "config.json";
pub use tracedecay_runtime_core::config::{
DB_FILENAME, TRACEDECAY_DIR, USER_DATA_DIR_ENV, active_data_dir_name, db_filename,
discover_project_root, get_project_db_path, get_tracedecay_dir, has_project_database,
user_data_dir,
is_ambient_project_root, user_data_dir,
};

/// Atomic project-scoped semantic runtime selection.
Expand Down
Loading
Loading