Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 47 additions & 0 deletions crates/cli/src/adapters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ pub(crate) mod claude_code;
pub(crate) mod codex;
pub(crate) mod hermes;

pub(crate) const SKILL_LOAD_SOURCE_KEY: &str = "skill_load_source";
pub(crate) const SKILL_LOAD_SOURCE_PROMPT_EXPANSION: &str = "prompt_expansion";

use axum::http::HeaderMap;
use serde_json::{Map, Value, json};
use uuid::Uuid;
Expand Down Expand Up @@ -781,6 +784,17 @@ fn classify(
)),
];
}
if normalized == "userpromptexpansion"
&& let Some(event) = inferred_skill_load_event(
payload,
headers,
rules.kind,
extractor,
&fallback_session_id,
)
{
return vec![NormalizedEvent::HookMark(event)];
}
let primary = classify_primary(payload, headers, extractor, rules, &fallback_session_id);
if normalized == "stop" && !primary.is_terminal() {
return vec![
Expand All @@ -797,6 +811,39 @@ fn classify(
vec![primary]
}

fn inferred_skill_load_event(
payload: &Value,
headers: &HeaderMap,
kind: AgentKind,
extractor: &dyn AgentPayloadExtractor,
fallback_session_id: &str,
) -> Option<SessionEvent> {
let expansion_type = string_at(payload, &["expansion_type"])?;
if normalize_name(&expansion_type) != "slashcommand" {
return None;
}
let skill_name = string_at(payload, &["command_name"])?;
let skill_name = skill_name.trim();
if skill_name.is_empty() {
return None;
}

let mut event =
common_session_event_with_fallback(payload, headers, kind, extractor, fallback_session_id);
event.payload = json!({"skill_name": skill_name});
if let Some(metadata) = event.metadata.as_object_mut() {
metadata.insert(
SKILL_LOAD_SOURCE_KEY.into(),
json!(SKILL_LOAD_SOURCE_PROMPT_EXPANSION),
);
metadata.insert("inferred".into(), json!(true));
if let Some(command_source) = string_at(payload, &["command_source"]) {
metadata.insert("command_source".into(), json!(command_source));
}
}
Some(event)
}

/// Classify a raw hook event using adapter-specific names before generic names.
///
/// Unknown events are intentionally converted to hook marks, not errors, so new
Expand Down
36 changes: 36 additions & 0 deletions crates/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,12 @@ async fn collect_agents(
if !hook_details.is_empty() {
details.push(hook_details);
}
if agent == CodingAgent::ClaudeCode
&& let Some(warning) = version.as_deref().and_then(claude_hook_floor_warning)
{
status = combine_status(status, Status::Warn, true);
details.push(warning);
}
out.push(AgentInfo {
name: display_name,
status,
Expand Down Expand Up @@ -577,6 +583,36 @@ fn hook_file_status(
}
}

// Claude Code validates plugin hooks.json against a strict event-name whitelist and rejects the
// entire plugin's hooks on one unknown name. 2.1.116 is the oldest release that accepts every
// event in the generated hook config (`UserPromptExpansion` was added to the whitelist there),
// so older hosts silently load no relay hooks at all. Keep in sync with `HOOK_EVENTS` in
// installer.rs.
const CLAUDE_HOOK_EVENT_FLOOR: (u64, u64, u64) = (2, 1, 116);

// Returns a doctor warning when a probed Claude Code version predates the hook-event floor.
// Unparseable version strings return None: a missing warning is recoverable, a false one is not.
fn claude_hook_floor_warning(version: &str) -> Option<String> {
let parsed = parse_leading_semver(version)?;
(parsed < CLAUDE_HOOK_EVENT_FLOOR).then(|| {
let (major, minor, patch) = CLAUDE_HOOK_EVENT_FLOOR;
format!(
"version predates {major}.{minor}.{patch}; this Claude Code rejects \
UserPromptExpansion and will silently load no relay hooks"
)
})
}

// Parses the leading `major.minor.patch` token from a probed version line such as
// "2.1.206 (Claude Code)". Suffixes like prerelease tags fail the numeric parse and yield None.
fn parse_leading_semver(version: &str) -> Option<(u64, u64, u64)> {
let mut parts = version.split_whitespace().next()?.splitn(3, '.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next()?.parse().ok()?;
let patch = parts.next()?.parse().ok()?;
Some((major, minor, patch))
}

async fn probe_version(binary: &Path) -> Option<String> {
// Spawn `<binary> --version` and read the first line of stdout. Bounded by the network
// timeout (re-used as a generic short timeout) so a misbehaving binary doesn't hang doctor.
Expand Down
14 changes: 9 additions & 5 deletions crates/cli/src/installer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@ use serde_json::{Value, json};
use crate::config::{CodingAgent, GatewayMode, HookForwardCommand};
use crate::error::CliError;

// Claude Code's hook loader strictly whitelists event names — any unknown event causes the
// entire hooks file to be rejected (no hooks register). Only events present in Claude Code's
// whitelist as of 2.1.x belong here. Codex 0.129 has a smaller subset (SessionStart,
// UserPromptSubmit, PreToolUse, PostToolUse, Stop, PreCompact, PostCompact, PermissionRequest)
// and silently ignores events it doesn't recognize, so the union list is safe for both agents.
// Claude Code validates plugin hooks.json against a strict event-name whitelist — one unknown
// event rejects the entire plugin's hooks (no hooks register, silently). Both Claude vectors
// (the transparent-run temp plugin and the marketplace plugin) are plugin hooks.json, so every
// event here must exist in the oldest supported Claude Code. UserPromptExpansion sets that
// floor: 2.1.116 (verified empirically; 2.1.114 rejects it — see `claude_hook_floor_warning`
// in doctor.rs). Codex 0.129 has a smaller subset (SessionStart, UserPromptSubmit, PreToolUse,
// PostToolUse, Stop, PreCompact, PostCompact, PermissionRequest) and silently ignores events
// it doesn't recognize, so the union list is safe for both agents.
const HOOK_EVENTS: &[&str] = &[
"SessionStart",
"UserPromptSubmit",
"UserPromptExpansion",
Comment thread
willkill07 marked this conversation as resolved.
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
Expand Down
15 changes: 14 additions & 1 deletion crates/cli/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use nemo_relay::api::tool::{
use serde_json::{Map, Value, json};
use tokio::sync::Mutex;

use crate::adapters::{SKILL_LOAD_SOURCE_KEY, SKILL_LOAD_SOURCE_PROMPT_EXPANSION};
use crate::alignment::{
self, GatewayManagementPolicy, PendingSubagentStart, SessionAlias, SessionAlignmentState,
insert_optional, json_string_at, json_value_at, merge_metadata,
Expand Down Expand Up @@ -950,7 +951,19 @@ impl Session {
NormalizedEvent::PromptSubmitted(event) => self.start_turn(event).await,
NormalizedEvent::Compaction(event) => self.mark("compaction", event),
NormalizedEvent::Notification(event) => self.mark("notification", event),
NormalizedEvent::HookMark(event) => self.mark("hook_mark", event),
NormalizedEvent::HookMark(event) => {
let name = if event
.metadata
.get(SKILL_LOAD_SOURCE_KEY)
.and_then(Value::as_str)
== Some(SKILL_LOAD_SOURCE_PROMPT_EXPANSION)
{
"skill.load.inferred"
} else {
"hook_mark"
};
self.mark(name, event)
}
Comment thread
willkill07 marked this conversation as resolved.
}
})
.await
Expand Down
117 changes: 117 additions & 0 deletions crates/cli/tests/coverage/adapters_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,123 @@ fn maps_claude_canonical_tool_payload() {
);
}

#[test]
fn preserves_supported_coding_agent_skill_load_tool_arguments() {
let cases = [
claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "PreToolUse",
"tool_use_id": "claude-skill",
"tool_name": "Skill",
"tool_input": {"skill": "review"}
}),
&HeaderMap::new(),
),
codex::adapt(
json!({
"session_id": "codex-session",
"hook_event_name": "PreToolUse",
"tool_use_id": "codex-skill",
"tool_name": "Bash",
"tool_input": {"command": "cat /workspace/skills/review/SKILL.md"}
}),
&HeaderMap::new(),
),
hermes::adapt(
json!({
"session_id": "hermes-session",
"hook_event_name": "pre_tool_call",
"tool_name": "skill_view",
"tool_input": {"name": "review"},
"extra": {"tool_call_id": "hermes-skill"}
}),
&HeaderMap::new(),
),
];

let expected = [
("Skill", json!({"skill": "review"})),
(
"Bash",
json!({"command": "cat /workspace/skills/review/SKILL.md"}),
),
("skill_view", json!({"name": "review"})),
];
for (outcome, (tool_name, arguments)) in cases.into_iter().zip(expected) {
match &outcome.events[0] {
NormalizedEvent::ToolStarted(event) => {
assert_eq!(event.tool_name, tool_name);
assert_eq!(event.arguments, arguments);
}
event => panic!("unexpected event: {event:?}"),
}
}
}

#[test]
fn maps_slash_command_expansion_to_minimal_inferred_skill_mark() {
let outcome = claude_code::adapt(
json!({
"session_id": "claude-session",
"hook_event_name": "UserPromptExpansion",
"expansion_type": "slash_command",
"command_name": "review",
"command_args": "123",
"command_source": "plugin",
"prompt": "/review 123"
}),
&HeaderMap::new(),
);

match &outcome.events[0] {
NormalizedEvent::HookMark(event) => {
assert_eq!(event.payload, json!({"skill_name": "review"}));
assert_eq!(
event.metadata[SKILL_LOAD_SOURCE_KEY],
SKILL_LOAD_SOURCE_PROMPT_EXPANSION
);
assert_eq!(event.metadata["inferred"], true);
assert_eq!(event.metadata["command_source"], "plugin");
assert!(event.metadata.get("prompt").is_none());
}
event => panic!("unexpected event: {event:?}"),
}
}

#[test]
fn does_not_infer_non_slash_or_empty_prompt_expansions() {
for payload in [
json!({
"session_id": "claude-session",
"hook_event_name": "UserPromptExpansion",
"expansion_type": "mcp_prompt",
"command_name": "review"
}),
json!({
"session_id": "claude-session",
"hook_event_name": "UserPromptExpansion",
"expansion_type": "slash_command",
"command_name": ""
}),
] {
let outcome = claude_code::adapt(payload, &HeaderMap::new());
match &outcome.events[0] {
NormalizedEvent::HookMark(event) => {
assert_ne!(
event
.metadata
.get(SKILL_LOAD_SOURCE_KEY)
.and_then(Value::as_str),
Some(SKILL_LOAD_SOURCE_PROMPT_EXPANSION)
);
assert_eq!(event.payload["hook_event_name"], "UserPromptExpansion");
}
event => panic!("unexpected event: {event:?}"),
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#[test]
fn maps_claude_post_tool_failure_with_canonical_fields() {
let headers = HeaderMap::new();
Expand Down
23 changes: 23 additions & 0 deletions crates/cli/tests/coverage/doctor_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1625,3 +1625,26 @@ fn format_agents_json_matches_doctor_agents_shape() {
assert_eq!(parsed[0]["version"], "2.1.4");
assert_eq!(parsed[0]["path"], "/opt/homebrew/bin/claude");
}

#[test]
fn claude_hook_floor_warning_pins_version_boundary() {
// 2.1.116 is the first Claude Code whose plugin hook whitelist accepts UserPromptExpansion;
// 2.1.114 is the newest published version that rejects it (2.1.115 was never published).
let cases = [
("2.1.114 (Claude Code)", true),
("2.1.116 (Claude Code)", false),
("2.1.206 (Claude Code)", false),
("2.0.999 (Claude Code)", true),
("3.0.0 (Claude Code)", false),
("2.1.116-beta (Claude Code)", false),
("not a version", false),
("", false),
];
for (version, expect_warning) in cases {
assert_eq!(
claude_hook_floor_warning(version).is_some(),
expect_warning,
"unexpected floor verdict for {version:?}"
);
}
}
7 changes: 7 additions & 0 deletions crates/cli/tests/coverage/installer_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ fn merge_hooks_is_idempotent_and_preserves_existing_entries() {
let twice = merge_hooks(once.clone(), generated).unwrap();
assert_eq!(once, twice);
assert_eq!(twice["hooks"]["Stop"].as_array().unwrap().len(), 2);
assert_eq!(
twice["hooks"]["UserPromptExpansion"]
.as_array()
.unwrap()
.len(),
1
);
}

#[test]
Expand Down
Loading
Loading