From 7d9397b16bd441ac916295dc5393938449f79213 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:47:57 +0800 Subject: [PATCH 01/48] feat: expand SessionControl/SessionMessage/SessionHistory to Expanded exposure --- .../src/agentic/tools/implementations/session_control_tool.rs | 2 +- .../src/agentic/tools/implementations/session_history_tool.rs | 2 +- .../src/agentic/tools/implementations/session_message_tool.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index af4df4a7cf..bfbaee38d6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -276,7 +276,7 @@ Arguments: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Expanded } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index b0339ec773..af57e28495 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -106,7 +106,7 @@ Examples: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Expanded } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index d53db6d2b3..b6aa1d8ed4 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -298,7 +298,7 @@ Allowed agent types when creating a session: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Collapsed + ToolExposure::Expanded } fn input_schema(&self) -> Value { From 210054e296e311f1dc2f0a6858f5a3996a0ab0d0 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:49:23 +0800 Subject: [PATCH 02/48] fix: resolve pre-existing deprecation warnings --- .../services-integrations/src/mcp/protocol/client_info.rs | 2 -- .../src/mcp/protocol/transport_remote.rs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs index 157ca7d3c1..6abed44a1e 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs @@ -8,8 +8,6 @@ pub fn create_mcp_client_info( ) -> ClientInfo { ClientInfo::new( ClientCapabilities::builder() - .enable_roots() - .enable_sampling() .enable_elicitation() .build(), Implementation::new(client_name, client_version), diff --git a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs index 77e984a27e..faa0a13bec 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs @@ -202,7 +202,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { } } - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(event_stream) } @@ -303,7 +303,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { From fa3848a22d65762b84b51aafec08f64b4ad8150e Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:58:34 +0800 Subject: [PATCH 03/48] feat: add session tools to Team mode and shared coding modes, dynamic agent_type --- .../agentic/agents/definitions/modes/team.rs | 6 +++ .../assembly/core/src/agentic/agents/mod.rs | 2 + .../core/src/agentic/agents/registry/query.rs | 17 +++++++++ .../implementations/session_control_tool.rs | 20 +++++++++- .../implementations/session_message_tool.rs | 37 +++++++++++-------- .../agent-runtime/src/session_control.rs | 19 ++-------- .../tests/session_control_contracts.rs | 2 +- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 75d45dd75a..47c119d777 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -40,6 +40,12 @@ impl TeamMode { "Git".to_string(), "ControlHub".to_string(), "GetFileDiff".to_string(), + "SessionControl".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 4c3ca32edf..47576d0766 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -125,6 +125,8 @@ pub fn shared_coding_mode_tools() -> Vec { "Git".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), ]; append_provider_group_tools(&mut tools, "core.canvas"); tools diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 3b5ce45d33..8334922745 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -123,6 +123,23 @@ impl AgentRegistry { result } + /// Return ids of all agents visible for session creation (modes + subagents). + pub async fn get_agent_ids_for_session_creation(&self) -> Vec { + self.ensure_user_custom_agents_loaded().await; + let map = self.read_agents(); + let mut ids: Vec = map + .values() + .filter(|e| { + matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent) + }) + .map(|e| e.agent.id().to_string()) + .collect(); + drop(map); + ids.sort(); + ids.dedup(); + ids + } + /// check if a subagent is readonly (used for TaskTool.is_concurrency_safe etc.) pub fn get_subagent_is_readonly(&self, id: &str) -> Option { if let Some(entry) = self.read_agents().get(id) { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index bfbaee38d6..ccbd32a8ea 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -302,8 +302,7 @@ Arguments: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Optional agent type when creating a session. Defaults to agentic." + "description": "Optional agent type when creating a session. Defaults to agentic. Available agent types are listed at runtime." } }, "required": ["action"], @@ -311,6 +310,23 @@ Arguments: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Optional agent type when creating a session. Defaults to agentic."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index b6aa1d8ed4..7dd6f9993c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -242,22 +242,11 @@ impl SessionMessageTool { } #[derive(Debug, Clone, Deserialize)] -enum SessionMessageAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} +struct SessionMessageAgentType(String); impl SessionMessageAgentType { - fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + fn as_str(&self) -> &str { + &self.0 } } @@ -323,8 +312,7 @@ Allowed agent types when creating a session: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Required when session_id is omitted. Not allowed when sending to an existing session." + "description": "Required when session_id is omitted. Not allowed when sending to an existing session. Available agent types are listed at runtime." } }, "required": ["message"], @@ -332,6 +320,23 @@ Allowed agent types when creating a session: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Required when session_id is omitted. Not allowed when sending to an existing session."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index bf0e4e44b7..532d518b96 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -24,23 +24,12 @@ impl SessionControlAction { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub enum SessionControlAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionControlAgentType(pub String); impl SessionControlAgentType { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + pub fn as_str(&self) -> &str { + &self.0 } } diff --git a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs index 57ebaf23b5..bdb698f99e 100644 --- a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs @@ -51,7 +51,7 @@ fn rejects_current_session_mutation_when_context_matches() { fn validates_create_requires_workspace_and_creator_session() { let mut input = base_input(SessionControlAction::Create); input.workspace = Some(std::env::temp_dir().to_string_lossy().to_string()); - input.agent_type = Some(SessionControlAgentType::Plan); + input.agent_type = Some(SessionControlAgentType("Plan".to_string())); let missing_creator = validate_session_control_input(&input, SessionControlValidationContext::default()); From 10e29c58d1da7b84f9743402b957b0343271a00e Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 17:18:11 +0800 Subject: [PATCH 04/48] feat: legion preset storage + ACP agents in core zone --- src/apps/desktop/src/api/agentic_api.rs | 27 ++++ src/apps/desktop/src/lib.rs | 5 + .../assembly/core/src/agentic/agents/mod.rs | 1 + .../core/src/agentic/agents/team_presets.rs | 117 ++++++++++++++++++ .../src/app/scenes/agents/AgentsScene.tsx | 60 ++++++--- .../src/app/scenes/agents/agentVisibility.ts | 20 ++- .../app/scenes/agents/hooks/useAgentsList.ts | 35 +++++- .../src/locales/en-US/scenes/agents.json | 3 + .../src/locales/zh-CN/scenes/agents.json | 3 + .../src/locales/zh-TW/scenes/agents.json | 3 + 10 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/team_presets.rs diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 4f17595936..df98efb9db 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2425,6 +2425,33 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } +// ── Legion preset commands ────────────────────────────────────────── + +#[tauri::command] +pub async fn list_legion_presets() -> Result, String> { + bitfun_core::agentic::agents::team_presets::list_presets() +} + +#[tauri::command] +pub async fn get_legion_preset(id: String) -> Result { + bitfun_core::agentic::agents::team_presets::get_preset(&id) +} + +#[tauri::command] +pub async fn create_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::create_preset(&preset) +} + +#[tauri::command] +pub async fn update_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::update_preset(&preset) +} + +#[tauri::command] +pub async fn delete_legion_preset(id: String) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::delete_preset(&id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3894d36f0c..06a33e8fd4 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -885,6 +885,11 @@ pub async fn run() { api::agentic_api::generate_session_title, api::agentic_api::get_available_modes, api::agentic_api::get_default_review_team_definition, + api::agentic_api::list_legion_presets, + api::agentic_api::get_legion_preset, + api::agentic_api::create_legion_preset, + api::agentic_api::update_legion_preset, + api::agentic_api::delete_legion_preset, api::btw_api::btw_ask_stream, api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 47576d0766..9ca3b39c2b 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,6 +5,7 @@ mod definitions; mod prompt_builder; mod registry; +pub mod team_presets; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs new file mode 100644 index 0000000000..067a911faa --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -0,0 +1,117 @@ +//! Legion preset storage. +//! +//! Each preset is a JSON file under `/legions/.json` describing +//! a team topology (nodes + edges) that the Team mode agent can materialise at +//! runtime via SessionControl / SessionMessage. + +use crate::infrastructure::get_path_manager_arc; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const LEGIONS_SUBDIR: &str = "legions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionPreset { + pub id: String, + pub name: String, + pub description: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionNode { + pub id: String, + pub agent: String, + pub role: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gate: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionEdge { + pub from: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, +} + +fn legions_dir() -> PathBuf { + get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) +} + +fn preset_path(id: &str) -> PathBuf { + legions_dir().join(format!("{id}.json")) +} + +fn ensure_legions_dir() -> std::io::Result<()> { + let dir = legions_dir(); + std::fs::create_dir_all(&dir) +} + +/// List all saved legion presets (sorted by id). +pub fn list_presets() -> Result, String> { + let dir = legions_dir(); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let entries = std::fs::read_dir(&dir).map_err(|e| format!("Failed to read legions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read dir entry: {e}"))?; + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "json") { + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let preset: LegionPreset = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + out.push(preset); + } + } + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +/// Load a single preset by id. +pub fn get_preset(id: &str) -> Result { + let path = preset_path(id); + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + let raw = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read preset: {e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse preset: {e}")) +} + +/// Create or overwrite a preset. +pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { + ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; + let path = preset_path(&preset.id); + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Update an existing preset (id must already exist). +pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { + let path = preset_path(&preset.id); + if !path.is_file() { + return Err(format!("Legion preset '{}' not found", preset.id)); + } + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Delete a preset by id. +pub fn delete_preset(id: &str) -> Result<(), String> { + let path = preset_path(id); + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {e}")) +} diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 3d480269d7..32d37b91bf 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -37,7 +37,7 @@ import { getAgentBadge, getAgentDescription, getCapabilityLabel } from './utils' import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; -import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; +import { isAgentInOverviewZone, buildCoreAgentIds, ACP_CORE_AGENT_PREFIX } from './agentVisibility'; import { CustomAgentAPI } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeSkillInfo } from '@/infrastructure/config/types'; @@ -249,26 +249,50 @@ const AgentsHomeView: React.FC = () => { }; }, []); - const coreAgentMeta = useMemo((): Record => ({ - agentic: { - role: t('coreAgentsZone.modes.agentic.role'), - ...CORE_AGENT_ACCENTS.agentic, - }, - Cowork: { - role: t('coreAgentsZone.modes.cowork.role'), - ...CORE_AGENT_ACCENTS.Cowork, - }, - ComputerUse: { - role: t('coreAgentsZone.modes.computerUse.role'), - ...CORE_AGENT_ACCENTS.ComputerUse, - }, - }), [t]); + const coreAgentMeta = useMemo((): Record => { + const meta: Record = { + agentic: { + role: t('coreAgentsZone.modes.agentic.role'), + ...CORE_AGENT_ACCENTS.agentic, + }, + Cowork: { + role: t('coreAgentsZone.modes.cowork.role'), + ...CORE_AGENT_ACCENTS.Cowork, + }, + ComputerUse: { + role: t('coreAgentsZone.modes.computerUse.role'), + ...CORE_AGENT_ACCENTS.ComputerUse, + }, + }; + // Append ACP external agent meta (use a neutral accent). + for (const agent of allAgents) { + if (agent.id.startsWith(ACP_CORE_AGENT_PREFIX) && !meta[agent.id]) { + meta[agent.id] = { + role: t('coreAgentsZone.modes.acpExternal.role', agent.name), + ...DEFAULT_CORE_AGENT_ACCENT, + }; + } + } + return meta; + }, [t, allAgents]); + + const acpEnabledIds = useMemo( + () => allAgents + .filter((a) => a.id.startsWith(ACP_CORE_AGENT_PREFIX)) + .map((a) => a.id.slice(ACP_CORE_AGENT_PREFIX.length)), + [allAgents], + ); - const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]); + const effectiveCoreAgentIds = useMemo(() => buildCoreAgentIds(acpEnabledIds), [acpEnabledIds]); + + const coreAgents = useMemo( + () => allAgents.filter((agent) => effectiveCoreAgentIds.has(agent.id)), + [allAgents, effectiveCoreAgentIds], + ); const visibleAgents = useMemo( - () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds)), - [filteredAgents, hiddenAgentIds], + () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds, effectiveCoreAgentIds)), + [filteredAgents, hiddenAgentIds, effectiveCoreAgentIds], ); const scrollToZone = useCallback((targetId: string) => { diff --git a/src/web-ui/src/app/scenes/agents/agentVisibility.ts b/src/web-ui/src/app/scenes/agents/agentVisibility.ts index 34031091ab..b814441e0d 100644 --- a/src/web-ui/src/app/scenes/agents/agentVisibility.ts +++ b/src/web-ui/src/app/scenes/agents/agentVisibility.ts @@ -18,13 +18,29 @@ export const HIDDEN_AGENT_IDS = new Set([ ...FALLBACK_REVIEW_HIDDEN_AGENT_IDS, ]); +/** Prefix used by ACP external agents (e.g. acp__codex, acp__Claude_Code). */ +export const ACP_CORE_AGENT_PREFIX = 'acp__'; + /** Core mode agents shown in the top zone only; excluded from overview zone list and counts. */ -export const CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); +const STATIC_CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); + +/** Build the effective core-agent id set including enabled ACP external agents. */ +export function buildCoreAgentIds(acpEnabledIds: string[]): Set { + const ids = new Set(STATIC_CORE_AGENT_IDS); + for (const acpId of acpEnabledIds) { + ids.add(`${ACP_CORE_AGENT_PREFIX}${acpId}`); + } + return ids; +} + +/** Legacy static set kept for backward-compat callers that don't inject ACP ids. */ +export const CORE_AGENT_IDS = STATIC_CORE_AGENT_IDS; /** Agents that appear in the bottom overview grid (same pool as filter chip counts). */ export function isAgentInOverviewZone( agent: { id: string }, hiddenAgentIds: ReadonlySet = HIDDEN_AGENT_IDS, + coreAgentIds: ReadonlySet = CORE_AGENT_IDS, ): boolean { - return !hiddenAgentIds.has(agent.id) && !CORE_AGENT_IDS.has(agent.id); + return !hiddenAgentIds.has(agent.id) && !coreAgentIds.has(agent.id); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 6e58362d4e..9732508a8a 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -9,10 +9,11 @@ import { useNotification } from '@/shared/notification-system'; import type { DynamicToolInfo } from '@/shared/types/agent-api'; import type { AgentWithCapabilities } from '../agentsStore'; import { enrichCapabilities } from '../utils'; -import { HIDDEN_AGENT_IDS, isAgentInOverviewZone } from '../agentVisibility'; +import { HIDDEN_AGENT_IDS, isAgentInOverviewZone, ACP_CORE_AGENT_PREFIX } from '../agentVisibility'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { loadDefaultReviewTeamDefinition } from '@/shared/services/reviewTeamService'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { ACPClientAPI, type AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; export type FilterLevel = 'all' | 'builtin' | 'user' | 'project'; export type FilterType = 'all' | 'mode' | 'subagent'; @@ -133,6 +134,14 @@ export function useAgentsList({ loadDefaultReviewTeamDefinition().catch(() => undefined), ]); + // Fetch enabled ACP external agents for the core zone. + let acpClients: AcpClientInfo[] = []; + try { + acpClients = (await ACPClientAPI.getClients()).filter((c) => c.enabled); + } catch { + // ACP not initialized — no external agents to show. + } + const profileMap = buildProfileMap(modes); const profileEntries = Object.values(profileMap); @@ -194,7 +203,29 @@ export function useAgentsList({ }), ); - setAllAgents([...modeAgents, ...subAgents]); + const acpAgents: AgentWithCapabilities[] = acpClients.map((client): AgentWithCapabilities => { + const agentId = `${ACP_CORE_AGENT_PREFIX}${client.id}`; + const displayName = client.name || client.id; + return enrichCapabilities({ + key: `acp::${client.id}`, + id: agentId, + name: displayName, + description: client.command + ? `External ACP agent (${client.command}${client.args.length ? ' ' + client.args.join(' ') : ''})` + : `External ACP agent — ${client.status}`, + isReadonly: client.readonly, + isReview: false, + toolCount: 0, + defaultTools: [], + defaultEnabled: client.enabled, + effectiveEnabled: client.enabled, + source: 'builtin' as AgentSource, + capabilities: [], + agentKind: 'subagent' as const, + }); + }); + + setAllAgents([...modeAgents, ...subAgents, ...acpAgents]); setAvailableTools(tools); setModeProfiles(profileMap); setModeSkills(Object.fromEntries(skillEntries)); diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index c60a86dce9..4065d252d2 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "Desktop automation agent" + }, + "acpExternal": { + "role": "External ACP agent — {{name}}" } } }, diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 3e3d9fb3e1..c4da2538e7 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "电脑操作智能体" + }, + "acpExternal": { + "role": "外部 ACP 智能体 — {{name}}" } } }, diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index cd00e2bf37..d2d24444fe 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "電腦操作智能體" + }, + "acpExternal": { + "role": "外部 ACP 智能體 — {{name}}" } } }, From acb23f3b38edb1fdc5cc6b90279dd151aad90ef8 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 17:23:11 +0800 Subject: [PATCH 05/48] feat: legion commander prompt + HookResult::Abort guard framework --- .../src/agentic/agents/prompts/team_mode.md | 332 ++++-------------- .../core/src/agentic/tools/post_call_hooks.rs | 15 +- .../src/agentic/tools/tool_context_runtime.rs | 22 +- .../agent-runtime/src/post_call_hooks.rs | 41 ++- 4 files changed, 133 insertions(+), 277 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index 30b1ce5b4e..ac7611052f 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md @@ -1,316 +1,108 @@ -You are BitFun in **Team Mode** — a virtual engineering team orchestrator. You coordinate specialized roles through a full sprint workflow to deliver high-quality software. - -You have access to a set of **gstack skills** via the Skill tool and BitFun's existing **Task** tool for launching sub-agents inside the same session. Each skill embodies a specialist role with deep expertise and a battle-tested methodology. Your job is to know WHEN to load each role's methodology, WHEN to dispatch independent work to existing sub-agents, and HOW to weave their outputs into a coherent delivery pipeline. - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. +You are BitFun in **Team Mode** — a legion commander. You orchestrate specialized agent sessions through a fractal deployment topology to deliver complex work. {LANGUAGE_PREFERENCE} -# MANDATORY: Built-in Runtime Boundary - -Team Mode is a BitFun built-in mode. It MUST be self-contained inside BitFun's runtime: - -- Do not require Claude Code, external gstack installs, external helper binaries, or files under `~/.claude`, `~/.gstack`, or repo-local skill-definition directories. -- Use only BitFun tools exposed in the current session, the bundled Skill contents, the Task tool's enabled sub-agents, and ordinary project tools such as `git`, `rg`, package-manager scripts, and test commands. -- Store any Team-owned durable artifacts under BitFun state paths such as `.bitfun/team/` or `$HOME/.bitfun/team/` when a skill asks for local team state. -- If a bundled skill mentions legacy helper behavior, reinterpret it through BitFun built-ins. Never ask the user to build, install, or enable an external helper just to make Team Mode work. - -# MANDATORY: Team-Orchestration Rule - -**Team Mode is not a single assistant pretending to be many people.** For non-trivial work, you MUST make the team visible by combining: - -1. **Skill**: load the role methodology and output contract. -2. **Task**: dispatch independent investigation / review / QA / research work to the existing enabled sub-agents in this workspace. -3. **Synthesis**: reconcile the role outputs in the main orchestrator before deciding or editing. - -Do not add or assume special built-in role sub-agent types. Use the sub-agents that the Task tool says are available in the current workspace. Prefer role-specific custom sub-agents when available; otherwise use general-purpose read-only sub-agents for investigation/review and keep implementation in the main Team session. +# Commander's Iron Rule -You MUST load the appropriate gstack skill before writing code, creating a final plan, or making file changes. This is not optional. Team Mode exists to run the specialist workflow with actual delegation where it helps. +**You only orchestrate. You never execute.** -There are only three exceptions to this rule: -1. The user explicitly says "skip [phase/skill], just do [X]" — respect it once, note the skip in your todo list -2. A pure config-only change (single file, zero logic) — Build → Review only -3. An emergency hotfix explicitly labeled as such — Investigate → Build → Review → Ship +All implementation, file operations, commands, and code changes MUST be delegated to legion members. Your role is task decomposition, agent creation, message dispatch, and quality gate enforcement. If you find yourself reaching for Read/Write/Edit/ExecCommand, you are doing it wrong. -In all other cases, invoke the skill first, then dispatch Task sub-agents for independent work whenever the phase contains separable investigation, review, testing, or audit tracks. +# Your Weapons -# Task Dispatch Rules +| Tool | Purpose | +|---|---| +| `SessionControl(action:"create")` | Create a new agent session (legion member node). `agent_type` accepts any registered agent ID, including Plan/agentic/Debug/Multitask/Team/DeepResearch/acp__* and custom agents. | +| `SessionControl(action:"list")` | List all sessions in the workspace. | +| `SessionControl(action:"cancel")` | Cancel a running session's turn. | +| `SessionControl(action:"delete")` | Remove a completed session. | +| `SessionMessage(session_id, message)` | Send a task to a legion member. The member executes asynchronously and automatically returns results via reply route. | +| `SessionHistory(session_id)` | Export a legion member's transcript for review. Use before gate decisions. | +| `Task(subagent_type, prompt, run_in_background)` | Dispatch a sub-agent for focused, scoped work inside a single session. | +| `get_goal` / `create_goal` / `update_goal` | Track campaign progress. Status flows: pending → in-progress → complete. Use `update_goal` to mark blocking when stuck. | -Use Task to create real team behavior without changing BitFun's global agent roster. +# The Three-Bee Atomic Unit -- Always read the Task tool's available agent list before choosing `subagent_type`; only use listed enabled sub-agents. -- Prefer custom user/project sub-agents whose name or description matches the role (`designer`, `security`, `qa`, `review`, `research`, etc.). -- If no suitable sub-agent exists, say so briefly and run that role in the main orchestrator after loading its Skill. -- Launch multiple independent Task calls in a single assistant message so BitFun runs them concurrently. -- Keep Task prompts small and owned: give each sub-agent its role, exact question, file/path scope, expected output format, and whether it is read-only. -- Never ask a Task sub-agent to mutate files unless the selected sub-agent is explicitly meant for that and the phase allows mutations. +Every legion member is a full agent session capable of independently reading, writing, executing commands, and communicating with other sessions via SessionMessage. Three specialized roles form the minimal execution unit: -# Your Team Roster +- **Prompt Bee**: Loads skills, retrieves methodology, prepares context before execution begins. +- **Execute Bee**: Performs the actual work — writes code, runs commands, produces output. +- **Review Bee**: Reads SessionHistory transcripts, audits behavior, and gates output quality. Does NOT execute. -These are the specialist roles available to you as skills. Invoke them via the **Skill** tool to load methodology, then dispatch existing Task sub-agents for separable work: +These three bees communicate directly via SessionMessage. They form an internal loop — review bee inspects output, sends corrections back to execute bee or prompt bee, and the cycle repeats until the gate passes. -| Role | Skill Name | When to Use | -|------|-----------|-------------| -| **YC Office Hours** | `office-hours` | User describes an idea or asks "is this worth building" — deep product thinking | -| **CEO Reviewer** | `plan-ceo-review` | Challenge scope, find the 10-star product hiding in the request | -| **Eng Manager** | `plan-eng-review` | Lock architecture, data flow, edge cases, test matrix | -| **Senior Designer** | `plan-design-review` | UI/UX audit, rate each design dimension, detect AI slop | -| **Independent Reviewer** | `CodeReview` via Task | Read-only adversarial review selected to match change risk | -| **QA Lead** | `qa` | Browser-based QA testing, find and fix bugs, regression tests | -| **QA Reporter** | `qa-only` | Same QA methodology but report-only, no code changes | -| **Release Engineer** | `ship` | Tests → PR → deploy. The last mile. | -| **Chief Security Officer** | `cso` | OWASP Top 10 + STRIDE threat model audit | -| **Debugger** | `investigate` | Systematic root-cause debugging with Iron Law: no fixes without root cause | -| **Auto-Review Pipeline (legacy, sequential)** | `autoplan` | Only when the user explicitly asks for the legacy single-thread pipeline. Default Phase 2 path is the parallel fan-out, not this. | -| **Designer Who Codes** | `design-review` | Design audit then fix what it finds with atomic commits | -| **Design Partner** | `design-consultation` | Build a complete design system from scratch | -| **Technical Writer** | `document-release` | Update all docs to match what was shipped | -| **Eng Manager (Retro)** | `retro` | Weekly engineering retrospective with per-person breakdowns | +# Deployment Protocol -# Skill Invocation Rules +## 1. Task Decomposition -The following table is **mandatory**. Match the user's request to the correct row and invoke the listed skill before doing anything else. +Analyze the user's request. Break it into independent subtasks. Each subtask that is atomic (cannot be meaningfully split further) is assigned to one agent session. -| If the user... | You MUST first invoke... | Only then can you... | -|----------------|--------------------------|----------------------| -| Describes a new idea, feature, or requirement | `office-hours` | Create any plan or design doc | -| Has a design doc or plan ready for review | the **parallel review fan-out** of Phase 2 (CEO + Eng + Design/CSO as applicable, in one message) | Write any code | -| Explicitly asks for the legacy sequential pipeline | `autoplan` | Write any code | -| Wants only one review type (CEO / Design / Eng) | the specific skill | Proceed to the next phase | -| Just finished writing code | `CodeReview` via Task | Proceed to QA or ship | -| Reports a bug or unexpected behavior | `investigate` | Touch any code | -| Says "ship it", "deploy", "create a PR" | `ship` | Run any deploy commands | -| Asks "does this work?" or "test this" | `qa` | Mark anything as done | -| Asks about security, auth, or data safety | `cso` | Modify any auth/data-related code | -| Wants design system or UI polish | `design-review` or `design-consultation` | Implement UI changes | -| Wants docs updated after shipping | `document-release` | Close out the task | -| Wants a retrospective | `retro` | Move to the next sprint | +Determine the dependency graph: which subtasks can run in parallel (no shared output dependency), and which must be serial (output of A feeds into B). -# The Sprint Workflow +## 2. Create Legion +For each subtask, create an agent session: ``` -Think → Plan → Build → Review → Test → Ship → Reflect +SessionControl(action:"create", session_name:"-", agent_type:"") ``` +Choose `agent_type` based on the role needed: Plan for analysis/design, agentic for implementation, DeepReview for quality gate, acp__* for external agents. -**MANDATORY: Every new feature or non-trivial change starts at Phase 1 (Think). Do not enter a later phase without completing all prior mandatory phases.** - -**Phases are sequential, but work *inside* a phase is parallel whenever possible.** In particular, all reviewer / audit / investigation tracks inside Phase 2 (Plan), Phase 4 (Review), and report-only QA/security checks MUST be fanned out with Task whenever there is a suitable existing sub-agent — see "Parallel Fan-out Protocol". - -## Phase 1: Think (REQUIRED for new ideas and features) - -**Entry condition:** User describes a new idea, feature, or requirement. - -**You MUST:** -1. Announce the role transition (see Role Transition Protocol below) -2. Invoke `office-hours` skill -3. Use Task only for independent discovery that sharpens the design doc (market/context research, codebase exploration, existing workflow mapping). Keep the final problem framing in the main orchestrator. -4. Produce the design doc -5. Confirm with the user before proceeding to Phase 2 - -**You must NOT write any code or create any implementation plan until Phase 1 is complete.** - -## Phase 2: Plan (REQUIRED before writing code) - -**Entry condition:** A design doc exists (from Phase 1 or provided by user). - -**You MUST:** -1. Announce the role transition once for the whole review batch (e.g. `[ROLE: Plan Review Council] Fanning out CEO + Design + Eng (+ CSO) in parallel...`). -2. Load the applicable reviewer skills, then **fan out reviewer work in parallel** by emitting **multiple `Task` tool calls in a single assistant message** (see "Parallel Fan-out Protocol" below). The applicable reviewers are: - - `plan-ceo-review` — strategic scope challenge (always) - - `plan-eng-review` — architecture and test plan (always) - - `plan-design-review` — UI/UX review (only if UI is involved) - - `cso` — security review (only if auth / data / network surface is touched) - - Do **not** invoke `autoplan` here — `autoplan` is sequential and is reserved for the case where the user explicitly asks for the legacy single-thread pipeline. -3. If a role has no suitable Task sub-agent, run that role in the main orchestrator using the loaded skill and mark it as `main-session`. -4. After all reviewers return, write a **Review Synthesis** block (see "Review Synthesis Template" below) that merges blocking issues, conflicts, and the final decision. -5. Get user approval on the synthesized plan before proceeding. - -**You must NOT write any code until Phase 2 is complete and the plan is approved.** - -## Phase 3: Build (ONLY after plan approval) - -**Entry condition:** Plan is approved from Phase 2. - -- Write code using standard tools (Read, Write, Edit, ExecCommand, etc.) -- Use TodoWrite to track implementation progress -- Follow the architecture decisions from the plan exactly - -## Phase 4: Review (REQUIRED before testing or shipping) - -**Entry condition:** Implementation is complete. - -**You MUST:** -1. Announce that an independent review is starting without exposing internal agent or Task names. -2. Dispatch one read-only `CodeReview` Task and include the relevant correctness, security, architecture, and UI lenses in its prompt. Do not choose a parallel reviewer count here; broader coverage belongs to the unified `/review` path and its cost confirmation. -3. Keep the reviewer read-only. The main Team session owns a separate remediation phase after findings are synthesized. -4. Write a **Review Synthesis** block organized by severity, evidence, and residual coverage rather than internal source roles. -5. Fix all AUTO-FIX issues immediately. Present ASK items to the user and wait for decisions. - -**You must NOT proceed to Test or Ship until all AUTO-FIX items are resolved.** - -## Phase 5: Test (REQUIRED before shipping) - -**Entry condition:** Review phase passed (no unresolved AUTO-FIX items). - -**You MUST:** -1. Announce the role transition -2. Invoke `qa` for browser-based testing (if UI is involved), or `qa-only` for report-only -3. Use Task with `ComputerUse` or another suitable QA/browser sub-agent when available; keep fix decisions in the main Team session unless the invoked QA workflow explicitly owns fixes. -4. Each bug found generates a regression test before the fix -5. Re-run independent `CodeReview` if significant code changes were made during QA - -## Phase 6: Ship (REQUIRED to close out the work) - -**Entry condition:** Tests pass. +## 3. Topological Sort and Fan-Out -**You MUST:** -1. Announce the role transition -2. Invoke `ship` to run final tests, create PR, and handle the release - -## Phase 7: Reflect (after shipping) - -- Invoke `retro` for a sprint retrospective -- Invoke `document-release` to update project docs to match what was shipped - -# Phase Gates - -These are hard stops. You cannot proceed past a gate without satisfying its condition. - -**Gate 1 — Before Build:** -A completed design doc OR an approved autoplan review output MUST exist. -If neither exists, announce: "Phase Gate 1: No design doc or plan found. Invoking office-hours now." Then invoke `office-hours`. - -**Gate 2 — Before Ship:** -Independent Review MUST have run and all accepted remediation items MUST be resolved. -If review has not run, announce: "Phase Gate 2: Review has not run. Starting independent review now." Then dispatch the appropriate `CodeReview` Task path. - -# Parallel Fan-out Protocol - -Team Mode is a **virtual team**, not a single specialist running serially. Parallelize independent planning, consultation, and discovery roles when suitable sub-agents are available. Product code Review is the exception: it uses one `CodeReview` Task here, while broader reviewer fan-out stays behind the unified `/review` policy and consent flow. - -**How to fan out:** - -- Emit **multiple `Task` tool calls inside one single assistant message** after loading the needed skill methodology. The platform's tool pipeline detects concurrency-safe calls and runs them with `join_all`. If you split them across separate assistant turns, you lose the parallelism and waste the user's time and tokens. -- Announce the batch **once** with a single role transition header (e.g. `[ROLE: Plan Review Council] Fanning out 3 reviewers in parallel...`). Do **not** print one transition header per skill in this case — that defeats the purpose of a batch. -- Pick only the reviewers that genuinely apply to the change. Do not invoke `plan-design-review` on a backend-only change just to fill the slate. -- Give every Task a role label in `description`, for example `CEO scope review`, `Eng architecture review`, `Security diff audit`, `QA browser smoke`. -- In every Task prompt, include: role, objective, scope/files, constraints, output format, and "return findings only; do not modify files" unless the phase explicitly allows that sub-agent to fix. - -**When NOT to fan out:** - -- Phases that produce artifacts the next step depends on (Build, Ship, Investigate root-cause loops). These remain sequential. -- The legacy `autoplan` skill — it is **sequential by design**. Only invoke `autoplan` if the user explicitly asks for it ("run autoplan", "do the full sequential pipeline"). The default path for Phase 2 is the parallel fan-out described above. -- A single reviewer scenario (e.g. user explicitly asked for "just the CEO review") — load that skill and decide whether one Task would materially improve evidence. Do not create parallelism for its own sake. - -**Concurrency safety:** - -- `Skill`, `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch`, and read-only `Task` calls are concurrency-safe and will run in parallel inside one batch. -- `Write`, `Edit`, `Delete`, `ExecCommand`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. - -# Review Synthesis Template - -After every parallel review batch (Phase 2 or Phase 4), you MUST emit a Review Synthesis block before continuing. Use this exact structure: +Sort subtasks by their dependency graph. All subtasks on the same level (no dependencies between them) are dispatched in parallel. +For each subtask in the current level: ``` ---- -## Review Synthesis (sources: , , ...) - -### Blocking issues (must resolve before next phase) -- [] — proposed fix: - -### Non-blocking suggestions -- [] - -### Conflicts between roles -- says X, says Y. Resolution: . - -### Agreements / consensus -- - -### Decision -- Proceed to / Block on user input / Re-run with . ---- +SessionMessage(session_id:"", message:"") ``` +Make every dispatch in a single assistant message so they run concurrently. -If a reviewer returned nothing actionable, still list them in the `sources:` line so the user can see who was consulted. This block is the single source of truth the orchestrator uses to gate the next phase. +## 4. Wait and Collect -# Role Transition Protocol +Each SessionMessage returns automatically when the agent completes its turn. Wait for all parallel dispatches to finish before proceeding to the next level. -When invoking any skill, you MUST announce the transition with this exact format before invoking the Skill tool: +## 5. Review and Gate -``` ---- -[ROLE: {Role Name}] Invoking {skill-name}... ---- -``` +After receiving output, use SessionHistory to inspect the agent's transcript. Verify: +- Did the agent read relevant files before editing? +- Did the agent verify its output (tests pass, commands succeed)? +- Are all acceptance criteria met? -Examples: -``` ---- -[ROLE: YC Office Hours] Invoking office-hours... ---- +If the output fails review, send corrections back: ``` +SessionMessage(session_id:"", message:"[CORRECTION] ") ``` ---- -[ROLE: Eng Manager] Invoking plan-eng-review... ---- -``` +Repeat until the gate passes. -After the skill completes, announce the return with this format: +## 6. Escalate -``` ---- -[ROLE: BitFun Orchestrator] {skill-name} complete. Moving to {next phase/action}. ---- -``` +When a subtask cannot be completed at the current level — the agent hit a complexity wall, discovered new dependencies, or the task itself decomposes further — create a new sub-legion. Decompose the stuck subtask into its own subtasks, create new agent sessions, and repeat the protocol recursively. -This makes the team structure visible. Never silently invoke a skill. +## 7. Complete Campaign -# When to Abbreviate the Workflow +When all subtasks pass their gates, mark the campaign complete: +``` +update_goal(status:"complete") +``` -The workflow can only be abbreviated in these specific cases. Skipping a phase does not mean skipping the mandatory skill — it means the phase genuinely does not apply. +# Fractal Nesting -| Scenario | Allowed shortcut | -|----------|-----------------| -| Pure config change (1 file, zero logic) | Build → Review only | -| Emergency hotfix (explicitly labeled) | Investigate → Build → Review → Ship | -| Bug report with clear root cause already known | Investigate → Build → Review → Ship | -| User explicitly invokes a specific skill by name | Go directly to that skill, then continue from that phase | -| Security audit only | Just invoke `cso` | +Any agent session you create is also capable of creating its own sub-sessions. A legion member stuck on a complex problem can itself become a commander. This is not a bug — it is the design. Each level only cares about the level directly below it. The topology is self-similar at every scale. -**In all other cases, start from the correct entry point in the Sprint Workflow.** +# Gate Rules -When a user says "run a review", "do QA", or "ship it" — those are explicit skill invocations. Honor them immediately. This is not a shortcut — it means the user is entering the workflow at a specific phase. +- **Never accept output that skips verification.** If an agent claims completion but ran no test/check commands, reject it. +- **Never accept output that skips reading.** If an agent edits a file without first reading it, reject it. +- **Never retry the same approach more than 3 times.** If an agent fails the same tool call repeatedly, it is stuck. Decompose the task differently or escalate. +- **Always use SessionHistory before gate decisions.** Do not trust the agent's summary — read the transcript. # Professional Objectivity -Prioritize technical accuracy over validating beliefs. The CEO reviewer and Eng Manager skills will challenge the user's assumptions — that is by design. Great products come from honest feedback, not agreement. +Prioritize technical accuracy over validating beliefs. Delegate to the right agent type for each task. Do not pretend to be many people in a single session — create real agent sessions for real parallelism. # Tone and Style - NEVER use emojis unless the user explicitly requests it -- Be concise when orchestrating between phases -- When a skill is loaded, follow its instructions precisely — the skill IS the expert -- Report phase transitions clearly using the Role Transition Protocol -- Use TodoWrite to track sprint progress across phases — each phase is a top-level todo - -# Task Management - -Use TodoWrite frequently to track sprint progress. Structure it as: -- Phase 1: Think — [status] -- Phase 2: Plan — [status] -- Phase 3: Build — [status] -- Phase 4: Review — [status] -- Phase 5: Test — [status] -- Phase 6: Ship — [status] - -Mark phases complete only after their mandatory skill has run and its output has been acted on. - -# Doing Tasks - -- NEVER propose changes to code you haven't read. Read first, then modify. -- Use the AskUserQuestion tool when you need user decisions between phases. -- Be careful not to introduce security vulnerabilities. -- When invoking a skill, trust its methodology and follow its instructions fully. -- If a skill's output contradicts the current plan, surface the conflict to the user before proceeding. +- Be concise when orchestrating +- Use TodoWrite to track the dependency graph and progress of each legion member +- Report gate results clearly: PASS (with evidence) or FAIL (with specific fix instruction) diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index db411435d2..09923908db 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -7,7 +7,7 @@ use crate::agentic::deep_review::tool_measurement; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor, + run_successful_tool_post_call_hooks, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::Value; @@ -22,13 +22,22 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec ) { tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context); } + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &ToolUseContext, + ) -> HookResult { + HookResult::Continue + } } pub(crate) fn record_successful_tool_call( tool_name: &str, input: &Value, context: &ToolUseContext, -) { +) -> HookResult { let mut executor = CorePostCallHookExecutor; - run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor); + run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor) } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index ffe37ddbab..bb31ffd983 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -206,7 +206,27 @@ pub(crate) async fn call_with_tool_runtime_hooks( }; if result.is_ok() { - post_call_hooks::record_successful_tool_call(tool_name, input, context); + let hook_result = post_call_hooks::record_successful_tool_call(tool_name, input, context); + if let bitfun_agent_runtime::post_call_hooks::HookResult::Abort { + reason, + fix_instruction, + .. + } = hook_result + { + let interception = serde_json::json!({ + "intercepted": true, + "reason": reason, + "fix_instruction": fix_instruction, + }); + return Ok(vec![ToolResult::Result { + data: interception, + result_for_assistant: Some(format!( + "[ToolGuard Intercepted] Result for tool {} was rejected.\nReason: {}\nFix: {}", + tool_name, reason, fix_instruction + )), + image_attachments: None, + }]); + } } result diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index a8ae2ac199..d1566f24fc 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -11,10 +11,14 @@ use std::path::Path; pub enum RuntimeHookKind { SuccessfulToolPostCall, DeepReviewSharedContextToolUse, + BehaviorGuard, } -pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 1] { - [RuntimeHookKind::DeepReviewSharedContextToolUse] +pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 2] { + [ + RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookKind::BehaviorGuard, + ] } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +30,22 @@ pub enum RuntimeHookErrorPolicy { RecordWarning, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookResult { + Continue, + Abort { + reason: String, + fix_instruction: String, + max_retries: u32, + }, +} + +impl HookResult { + pub fn is_abort(&self) -> bool { + matches!(self, HookResult::Abort { .. }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeHookPlan { id: String, @@ -151,6 +171,13 @@ pub trait SuccessfulToolPostCallHookExecutor { input: &Value, context: &C, ); + + fn behavior_guard( + &mut self, + tool_name: &str, + input: &Value, + context: &C, + ) -> HookResult; } pub fn run_successful_tool_post_call_hooks( @@ -158,7 +185,8 @@ pub fn run_successful_tool_post_call_hooks( input: &Value, context: &C, executor: &mut E, -) where +) -> HookResult +where E: SuccessfulToolPostCallHookExecutor, { for hook in successful_tool_post_call_hooks() { @@ -166,9 +194,16 @@ pub fn run_successful_tool_post_call_hooks( RuntimeHookKind::DeepReviewSharedContextToolUse => { executor.record_deep_review_shared_context_tool_use(tool_name, input, context); } + RuntimeHookKind::BehaviorGuard => { + let result = executor.behavior_guard(tool_name, input, context); + if result.is_abort() { + return result; + } + } RuntimeHookKind::SuccessfulToolPostCall => {} } } + HookResult::Continue } #[derive(Debug, Clone, Copy)] From 3210f45c54a17c789dc169500b2bf779e8622f7f Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 18:03:45 +0800 Subject: [PATCH 06/48] feat: legion UI components + 18 orchestration patterns --- .../agents/components/CreateLegionPage.tsx | 150 +++++++ .../scenes/agents/components/LegionCard.scss | 99 +++++ .../scenes/agents/components/LegionCard.tsx | 86 ++++ .../agents/data/orchestration-patterns.ts | 392 ++++++++++++++++++ 4 files changed, 727 insertions(+) create mode 100644 src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx create mode 100644 src/web-ui/src/app/scenes/agents/components/LegionCard.scss create mode 100644 src/web-ui/src/app/scenes/agents/components/LegionCard.tsx create mode 100644 src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts diff --git a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx new file mode 100644 index 0000000000..a14ef9e928 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { ArrowLeft, GitBranch, Network } from 'lucide-react'; +import { Button, IconButton } from '@/component-library'; +import PATTERNS, { + type LegionPatternNode, + type LegionPatternEdge, +} from '../data/orchestration-patterns'; +import '../AgentsView.scss'; + +interface CreateLegionPageProps { + onBack: () => void; +} + +const CreateLegionPage: React.FC = ({ onBack }) => { + const [selectedPatternId, setSelectedPatternId] = useState(PATTERNS[0]?.id ?? ''); + + const patternOptions = useMemo(() => PATTERNS, []); + + const selectedPattern = useMemo( + () => patternOptions.find((p) => p.id === selectedPatternId) ?? null, + [patternOptions, selectedPatternId], + ); + + const handleSelectPattern = useCallback((id: string) => { + setSelectedPatternId(id); + }, []); + + const handleSave = useCallback(() => { + // TODO: save legion preset to backend via Tauri command + onBack(); + }, [onBack]); + + const renderNodeList = (nodes: LegionPatternNode[]) => ( +
+ {nodes.map((node, i) => ( +
+ {i + 1} +
+ {node.role} + {node.agent} +
+ {node.gate ? GATE : null} +
+ ))} +
+ ); + + const renderEdgeList = (edges: LegionPatternEdge[], nodes: LegionPatternNode[]) => ( +
+ {edges.map((edge) => { + const fromNode = nodes.find((n) => n.id === edge.from); + const toNode = nodes.find((n) => n.id === edge.to); + return ( +
${edge.to}`} className="legion-edge-item"> + {fromNode?.role ?? edge.from} + + {toNode?.role ?? edge.to} + {edge.condition ? ( + [{edge.condition}] + ) : null} +
+ ); + })} +
+ ); + + return ( +
+
+ + + +

+ {selectedPattern ? selectedPattern.name : 'Choose a Pattern'} +

+
+ + {/* Pattern selector */} +
+

Orchestration Patterns

+
+ {patternOptions.map((pattern) => ( +
handleSelectPattern(pattern.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)} + data-testid="legion-pattern-option" + data-pattern-id={pattern.id} + > + + {pattern.name} +
+ ))} +
+
+ + {selectedPattern ? ( + <> + {/* Summary */} +
+

Overview

+

{selectedPattern.description}

+
+ Complexity: L{selectedPattern.complexityLevel} + {selectedPattern.nodes.length} nodes + {selectedPattern.edges.length} edges +
+
+ + {/* Nodes */} +
+

+ Nodes ({selectedPattern.nodes.length}) +

+ {renderNodeList(selectedPattern.nodes)} +
+ + {/* Edges */} +
+

+ Edges ({selectedPattern.edges.length}) +

+ {selectedPattern.edges.length > 0 + ? renderEdgeList(selectedPattern.edges, selectedPattern.nodes) + :

No edges (self-contained agent)

} +
+ + {/* Actions */} +
+ + +
+ + ) : null} +
+ ); +}; + +export default CreateLegionPage; diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss new file mode 100644 index 0000000000..957aaf6cfb --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss @@ -0,0 +1,99 @@ +.legion-card { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 10px; + background: var(--color-surface-card); + border: 1px solid var(--color-border-subtle); + transition: border-color 0.15s, box-shadow 0.15s; + + &:hover { + border-color: var(--color-border-accent); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); + } + + &__header { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &__icon-area { + flex-shrink: 0; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--color-surface-accent); + color: var(--color-text-accent); + display: flex; + align-items: center; + justify-content: center; + } + + &__header-info { + flex: 1; + min-width: 0; + } + + &__title-row { + display: flex; + align-items: center; + gap: 6px; + } + + &__name { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__badges { + display: flex; + gap: 4px; + flex-shrink: 0; + } + + &__body { + flex: 1; + } + + &__desc { + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px; + } + + &__meta { + display: flex; + gap: 10px; + } + + &__meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-tertiary); + } +} diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx new file mode 100644 index 0000000000..498f8effb5 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { GitBranch, Users, Network } from 'lucide-react'; +import { Badge } from '@/component-library'; +import type { LegionPattern } from '../data/orchestration-patterns'; +import './LegionCard.scss'; + +interface LegionCardProps { + pattern: LegionPattern; + index?: number; + onOpenDetails: (pattern: LegionPattern) => void; +} + +const COMPLEXITY_LABELS: Record = { + 1: 'L1', + 2: 'L2-L3', + 3: 'L3', + 4: 'L4', + 5: 'L5-L6', + 6: 'L6', + 7: 'L7', +}; + +const LegionCard: React.FC = ({ + pattern, + index = 0, + onOpenDetails, +}) => { + const gateNodes = pattern.nodes.filter((n) => n.gate).length; + const openDetails = () => onOpenDetails(pattern); + + return ( +
e.key === 'Enter' && openDetails()} + aria-label={pattern.name} + data-testid="legion-list-item" + data-legion-id={pattern.id} + > +
+
+
+ +
+
+
+
+ {pattern.name} +
+ + {COMPLEXITY_LABELS[pattern.complexityLevel] ?? `L${pattern.complexityLevel}`} + +
+
+
+
+ +
+

{pattern.description}

+
+ +
+
+ + + {pattern.nodes.length} nodes + + + + {pattern.edges.length} edges + + {gateNodes > 0 ? ( + + {gateNodes} gate + + ) : null} +
+
+
+ ); +}; + +export default LegionCard; diff --git a/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts new file mode 100644 index 0000000000..ca7e440bce --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts @@ -0,0 +1,392 @@ +/** + * 18 built-in orchestration patterns for legion templates. + * Each pattern maps to the orchestration-patterns skill library. + */ +export interface LegionPatternNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPatternEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPattern { + id: string; + name: string; + description: string; + complexityLevel: number; + nodes: LegionPatternNode[]; + edges: LegionPatternEdge[]; +} + +const PATTERNS: LegionPattern[] = [ + { + id: 'sparc-dev', + name: 'SPARC Development', + description: '5-stage SPARC development pipeline: specification → pseudocode → architecture → refinement → completion', + complexityLevel: 4, + nodes: [ + { id: 'researcher', agent: 'Plan', role: 'Research Bee', prompt: 'Gather requirements, define acceptance criteria, identify constraints and edge cases.' }, + { id: 'decomposer', agent: 'Plan', role: 'Decompose Bee', prompt: 'Decompose into executable sub-tasks, annotate complexity, define dependencies.' }, + { id: 'architect', agent: 'agentic', role: 'Architect Bee', prompt: 'Design modules, define interfaces, resolve constraints.' }, + { id: 'implementer', agent: 'agentic', role: 'Implement Bee', prompt: 'Implement according to architecture and interface contracts.' }, + { id: 'tester', agent: 'agentic', role: 'Test Bee', prompt: 'Write and run automated tests. Coverage ≥ 80%, all ACs pass.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Code review and documentation generation.', gate: true }, + ], + edges: [ + { from: 'researcher', to: 'decomposer' }, + { from: 'decomposer', to: 'architect' }, + { from: 'architect', to: 'implementer' }, + { from: 'architect', to: 'tester' }, + { from: 'implementer', to: 'reviewer' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'implementer', condition: 'fail' }, + { from: 'reviewer', to: 'tester', condition: 'fail' }, + ], + }, + { + id: 'cicd-pipeline', + name: 'CI/CD Pipeline', + description: 'Lint → Unit test → Build → Integration test → Security audit → Deploy → Verify', + complexityLevel: 5, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Run linter, type checker, security scan. Gate: zero errors.' }, + { id: 'unit-test', agent: 'agentic', role: 'Unit Test Bee', prompt: 'Run unit tests across multiple environments. Gate: all pass, coverage ≥ 80%.' }, + { id: 'build', agent: 'agentic', role: 'Build Bee', prompt: 'Compile, package, upload artifact. Gate: build succeeds.' }, + { id: 'integration', agent: 'agentic', role: 'Integration Bee', prompt: 'Deploy to staging, run integration tests, smoke test.' }, + { id: 'security-audit', agent: 'agentic', role: 'Security Bee', prompt: 'Dependency vulnerability scan, container scan, compliance check.' }, + { id: 'deploy', agent: 'agentic', role: 'Deploy Bee', prompt: 'Rollout with health check. Gate: health passes.' }, + { id: 'verify', agent: 'DeepReview', role: 'Verify Bee', prompt: 'Smoke test production, monitor metrics, rollback if needed.', gate: true }, + ], + edges: [ + { from: 'lint', to: 'unit-test' }, + { from: 'unit-test', to: 'build' }, + { from: 'build', to: 'integration' }, + { from: 'integration', to: 'security-audit' }, + { from: 'security-audit', to: 'deploy' }, + { from: 'deploy', to: 'verify' }, + ], + }, + { + id: 'fan-out-converge', + name: 'Fan-out Converge', + description: 'Dispatch → Parallel research (N bees) → Synthesize → Final review', + complexityLevel: 5, + nodes: [ + { id: 'dispatch', agent: 'Team', role: 'Commander', prompt: 'Evaluate task, match pattern, build team, assign sub-goals.' }, + { id: 'researcher-1', agent: 'agentic', role: 'Research Bee A', prompt: 'Research scope A independently and report structured results.' }, + { id: 'researcher-2', agent: 'agentic', role: 'Research Bee B', prompt: 'Research scope B independently and report structured results.' }, + { id: 'researcher-3', agent: 'agentic', role: 'Research Bee C', prompt: 'Research scope C independently and report structured results.' }, + { id: 'synthesizer', agent: 'agentic', role: 'Synthesize Bee', prompt: 'Collect results, resolve conflicts, merge outputs, check consistency.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review merged output, generate final report.', gate: true }, + ], + edges: [ + { from: 'dispatch', to: 'researcher-1' }, + { from: 'dispatch', to: 'researcher-2' }, + { from: 'dispatch', to: 'researcher-3' }, + { from: 'researcher-1', to: 'synthesizer' }, + { from: 'researcher-2', to: 'synthesizer' }, + { from: 'researcher-3', to: 'synthesizer' }, + { from: 'synthesizer', to: 'reviewer' }, + { from: 'reviewer', to: 'synthesizer', condition: 'fail' }, + ], + }, + { + id: 'triad-minimal', + name: 'Three-Bee Minimal', + description: 'Prompt Bee → Execute Bee → Review Bee. Atomic execution unit.', + complexityLevel: 2, + nodes: [ + { id: 'prompt-bee', agent: 'Plan', role: 'Prompt Bee', prompt: 'Analyze task, inject relevant skills and templates.' }, + { id: 'execute-bee', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute the task using the provided methodology.' }, + { id: 'review-bee', agent: 'DeepReview', role: 'Review Bee', prompt: 'Audit behavior and output, gate pass/fail.', gate: true }, + ], + edges: [ + { from: 'prompt-bee', to: 'execute-bee' }, + { from: 'execute-bee', to: 'review-bee' }, + { from: 'review-bee', to: 'execute-bee', condition: 'fail' }, + { from: 'review-bee', to: 'prompt-bee', condition: 'fail' }, + ], + }, + { + id: 'state-machine', + name: 'State Machine', + description: 'Multi-state flow with conditional branches and escalation.', + complexityLevel: 6, + nodes: [ + { id: 'pending', agent: 'Plan', role: 'Assess Bee', prompt: 'Evaluate task complexity and route to appropriate state.' }, + { id: 'executing', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute. On success → review. On failure (≤3) → retry. On failure (>3) → escalate.' }, + { id: 'reviewing', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review. On pass → complete. On fix (≤3 rounds) → back to executing.' }, + { id: 'escalated', agent: 'Team', role: 'Escalation', prompt: 'Human-in-the-loop decision: confirm fix or abandon.' }, + { id: 'completed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate completion report.' }, + { id: 'failed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate failure report with root cause.' }, + ], + edges: [ + { from: 'pending', to: 'executing' }, + { from: 'executing', to: 'reviewing', condition: 'success' }, + { from: 'executing', to: 'failed', condition: 'exhausted' }, + { from: 'reviewing', to: 'completed', condition: 'pass' }, + { from: 'reviewing', to: 'executing', condition: 'fix' }, + { from: 'reviewing', to: 'escalated', condition: 'max_rounds' }, + { from: 'escalated', to: 'executing', condition: 'confirm' }, + { from: 'escalated', to: 'failed', condition: 'abandon' }, + ], + }, + { + id: 'deep-research', + name: 'Deep Research', + description: '6-phase research pipeline with parallel specialists, debate, and arbitration.', + complexityLevel: 6, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Query understanding, ambiguity detection, sub-question decomposition.' }, + { id: 'primary', agent: 'agentic', role: 'Primary Source', prompt: 'Primary source specialist research.' }, + { id: 'news', agent: 'agentic', role: 'News Specialist', prompt: 'News and timeline research.' }, + { id: 'expert', agent: 'agentic', role: 'Expert Opinion', prompt: 'Expert opinion research.' }, + { id: 'counter', agent: 'agentic', role: 'Counter Evidence', prompt: 'Counter-evidence research.' }, + { id: 'advocate', agent: 'agentic', role: 'Advocate', prompt: 'Defend findings in adversarial debate.' }, + { id: 'critic', agent: 'agentic', role: 'Critic', prompt: 'Challenge findings in adversarial debate.' }, + { id: 'fact-checker', agent: 'agentic', role: 'Fact Checker', prompt: 'Resolve conflicts into HARD_CONFLICT / GENUINE_UNCERTAINTY / UNVERIFIED.' }, + { id: 'arbitrator', agent: 'DeepReview', role: 'Arbitrator', prompt: 'Research Manager arbitration with verdict markers.', gate: true }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate final report with citation index.' }, + ], + edges: [ + { from: 'planner', to: 'primary' }, + { from: 'planner', to: 'news' }, + { from: 'planner', to: 'expert' }, + { from: 'planner', to: 'counter' }, + { from: 'primary', to: 'advocate' }, + { from: 'news', to: 'advocate' }, + { from: 'expert', to: 'advocate' }, + { from: 'counter', to: 'critic' }, + { from: 'advocate', to: 'fact-checker' }, + { from: 'critic', to: 'fact-checker' }, + { from: 'fact-checker', to: 'arbitrator' }, + { from: 'arbitrator', to: 'reporter', condition: 'pass' }, + { from: 'arbitrator', to: 'fact-checker', condition: 'contest' }, + ], + }, + { + id: 'react-loop', + name: 'ReAct Loop', + description: 'Thought → Action → Observation loop with stop condition.', + complexityLevel: 1, + nodes: [ + { id: 'react-agent', agent: 'agentic', role: 'ReAct Agent', prompt: 'Think → Act → Observe loop until stop condition or final answer.' }, + ], + edges: [], + }, + { + id: 'plan-exec-reflect', + name: 'Plan-Execute-Reflect', + description: 'Plan → Execute step by step → Draft → Reflect and critique → Refine or stop.', + complexityLevel: 3, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create a structured plan with dependencies.' }, + { id: 'executor', agent: 'agentic', role: 'Executor', prompt: 'Execute the plan step by step.' }, + { id: 'reflector', agent: 'DeepReview', role: 'Reflector', prompt: 'Reflect on the draft, critique quality, decide refine or stop.', gate: true }, + ], + edges: [ + { from: 'planner', to: 'executor' }, + { from: 'executor', to: 'reflector' }, + { from: 'reflector', to: 'executor', condition: 'refine' }, + ], + }, + { + id: 'event-driven', + name: 'Event-Driven Response', + description: 'Detect → Classify → Triage → Resolve → Postmortem. For incidents and alerts.', + complexityLevel: 4, + nodes: [ + { id: 'detector', agent: 'agentic', role: 'Detector', prompt: 'Detect event source, classify severity (P0-P4), tag category.' }, + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Assess impact, identify root cause, propose fix.' }, + { id: 'resolver', agent: 'agentic', role: 'Resolver', prompt: 'Apply fix, verify resolution, restore service.' }, + { id: 'postmortem', agent: 'agentic', role: 'Postmortem', prompt: 'Document timeline, identify prevention, generate report.' }, + ], + edges: [ + { from: 'detector', to: 'triage' }, + { from: 'triage', to: 'resolver' }, + { from: 'resolver', to: 'postmortem' }, + ], + }, + { + id: 'coding-agent', + name: 'Coding Agent', + description: 'Repo inspection → Scoped plan → File edits → Tests & checks → Patch & summary.', + complexityLevel: 3, + nodes: [ + { id: 'inspector', agent: 'agentic', role: 'Inspector', prompt: 'Inspect repository structure, understand codebase.' }, + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create scoped implementation plan.' }, + { id: 'editor', agent: 'agentic', role: 'Editor', prompt: 'Implement changes with minimal diff.' }, + { id: 'tester', agent: 'agentic', role: 'Tester', prompt: 'Run tests and checks.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Reviewer', prompt: 'Review diff, logs, summary.', gate: true }, + ], + edges: [ + { from: 'inspector', to: 'planner' }, + { from: 'planner', to: 'editor' }, + { from: 'editor', to: 'tester' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'editor', condition: 'fail' }, + ], + }, + { + id: 'dag-data-pipeline', + name: 'DAG Data Pipeline', + description: 'Extract → Transform (parallel partitions) → Validate → Load → Report.', + complexityLevel: 4, + nodes: [ + { id: 'extract', agent: 'agentic', role: 'Extractor', prompt: 'Connect source, validate connection, pull incremental data.' }, + { id: 'transform-a', agent: 'agentic', role: 'Transform A', prompt: 'Clean and transform partition A.' }, + { id: 'transform-b', agent: 'agentic', role: 'Transform B', prompt: 'Clean and transform partition B.' }, + { id: 'validator', agent: 'agentic', role: 'Validator', prompt: 'Run quality rules, check anomalies, generate quality report.' }, + { id: 'loader', agent: 'agentic', role: 'Loader', prompt: 'Connect target, write data, verify row count.' }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate execution report, log metrics.' }, + ], + edges: [ + { from: 'extract', to: 'transform-a' }, + { from: 'extract', to: 'transform-b' }, + { from: 'transform-a', to: 'validator' }, + { from: 'transform-b', to: 'validator' }, + { from: 'validator', to: 'loader' }, + { from: 'loader', to: 'reporter' }, + ], + }, + { + id: 'pr-code-review', + name: 'PR Code Review', + description: 'PR created → Lint → Code review (max 3 rounds) → Merge → Deploy.', + complexityLevel: 3, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Check diff size, run automated lint, verify PR template.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review logic, check test coverage, verify no regression.' }, + { id: 'merger', agent: 'agentic', role: 'Merge Bee', prompt: 'Rebase, resolve conflicts, run CI again.' }, + { id: 'deployer', agent: 'agentic', role: 'Deploy Bee', prompt: 'Deploy with promotion staging → production.' }, + ], + edges: [ + { from: 'lint', to: 'reviewer' }, + { from: 'reviewer', to: 'merger', condition: 'approved' }, + { from: 'reviewer', to: 'lint', condition: 'changes_requested' }, + { from: 'merger', to: 'deployer' }, + ], + }, + { + id: 'deploy-orchestration', + name: 'Deploy Orchestration', + description: 'Configure → Schedule → Health check → Rolling update → Self-heal loop.', + complexityLevel: 5, + nodes: [ + { id: 'configure', agent: 'agentic', role: 'Config Bee', prompt: 'Define desired state, set resource limits, configure probes.' }, + { id: 'scheduler', agent: 'agentic', role: 'Schedule Bee', prompt: 'Match nodes, pull images, start containers.' }, + { id: 'health-check', agent: 'agentic', role: 'Health Bee', prompt: 'Readiness, liveness, startup probes.' }, + { id: 'updater', agent: 'agentic', role: 'Update Bee', prompt: 'Rolling update, verify each batch, zero downtime.' }, + { id: 'healer', agent: 'agentic', role: 'Healer Bee', prompt: 'Continuous pod/node health monitoring, auto-restart/scale/migrate.' }, + ], + edges: [ + { from: 'configure', to: 'scheduler' }, + { from: 'scheduler', to: 'health-check' }, + { from: 'health-check', to: 'updater' }, + { from: 'updater', to: 'healer' }, + ], + }, + { + id: 'six-layer-runtime', + name: 'Six-Layer Agent Runtime', + description: 'Intent dispatch → State & memory → Execution sandbox → Tool boundary → Control → Endpoint.', + complexityLevel: 7, + nodes: [ + { id: 'intent', agent: 'Team', role: 'Intent Layer', prompt: 'Receive task/event, dispatch to appropriate handler, spawn sub-agents.' }, + { id: 'state', agent: 'agentic', role: 'State Layer', prompt: 'Manage working memory, persist artifacts, create checkpoints.' }, + { id: 'exec', agent: 'agentic', role: 'Exec Layer', prompt: 'Execute in sandbox/container with appropriate environment.' }, + { id: 'tool', agent: 'agentic', role: 'Tool Layer', prompt: 'Bridge to MCP/A2A/ANP protocols, call external tools.' }, + { id: 'control', agent: 'DeepReview', role: 'Control Layer', prompt: 'Policy approval, behavior evaluation, guard enforcement.' }, + { id: 'endpoint', agent: 'agentic', role: 'Endpoint Layer', prompt: 'Deliver results to user interface or API consumer.' }, + ], + edges: [ + { from: 'intent', to: 'state' }, + { from: 'state', to: 'exec' }, + { from: 'exec', to: 'tool' }, + { from: 'tool', to: 'control' }, + { from: 'control', to: 'endpoint' }, + ], + }, + { + id: 'memory-retrieval', + name: 'Memory & Retrieval', + description: 'Working memory → Promote/discard → Episodic/Semantic memory → Retrieval → Notes → Task context.', + complexityLevel: 4, + nodes: [ + { id: 'working', agent: 'agentic', role: 'Working Memory', prompt: 'Current session state, lightweight, in-process.' }, + { id: 'episodic', agent: 'agentic', role: 'Episodic Store', prompt: 'Store bounded events with structured metadata + similarity search.' }, + { id: 'semantic', agent: 'agentic', role: 'Semantic Store', prompt: 'Persist cross-task facts, dedup, normalize relations.' }, + { id: 'retrieval', agent: 'agentic', role: 'Retrieval Layer', prompt: 'Hybrid search: keyword + dense retrieval + structured filters.' }, + { id: 'context', agent: 'agentic', role: 'Context Builder', prompt: 'Assemble notes and artifacts into task context for model call.' }, + ], + edges: [ + { from: 'working', to: 'episodic' }, + { from: 'working', to: 'semantic' }, + { from: 'episodic', to: 'retrieval' }, + { from: 'semantic', to: 'retrieval' }, + { from: 'retrieval', to: 'context' }, + ], + }, + { + id: 'customer-support', + name: 'Customer Support', + description: 'Triage → Policy grounding → Draft → Guardrails → Human review queue.', + complexityLevel: 3, + nodes: [ + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Classify case type, urgency, sentiment, requested outcome.' }, + { id: 'policy', agent: 'agentic', role: 'Policy Agent', prompt: 'Ground response in explicit policy documents.' }, + { id: 'drafter', agent: 'agentic', role: 'Drafter', prompt: 'Draft response. Never auto-send — final decision is human.' }, + { id: 'guard', agent: 'DeepReview', role: 'Guardrail', prompt: 'Reject refunds, legal commitments, high-risk actions.', gate: true }, + ], + edges: [ + { from: 'triage', to: 'policy' }, + { from: 'policy', to: 'drafter' }, + { from: 'drafter', to: 'guard' }, + { from: 'guard', to: 'drafter', condition: 'fail' }, + ], + }, + { + id: 'evaluation-observability', + name: 'Evaluation & Observability', + description: 'Offline eval → Online monitoring → Structured traces → Failure triage.', + complexityLevel: 5, + nodes: [ + { id: 'offline', agent: 'agentic', role: 'Offline Eval', prompt: 'Run benchmarks on known tasks, compare prompts/models/tools.' }, + { id: 'online', agent: 'agentic', role: 'Online Monitor', prompt: 'Collect production signals: success rate, latency, escalation rate.' }, + { id: 'tracer', agent: 'agentic', role: 'Tracer', prompt: 'Capture structured traces: tool inputs/outputs, state transitions.' }, + { id: 'triage', agent: 'DeepReview', role: 'Triage', prompt: 'Failure triage from traces: prompt / tool / model decisions.' }, + ], + edges: [ + { from: 'offline', to: 'triage' }, + { from: 'online', to: 'triage' }, + { from: 'tracer', to: 'triage' }, + ], + }, + { + id: 'workflow-agent-hybrid', + name: 'Workflow-Agent Hybrid', + description: 'Known path → workflow. Unknown path → agent. Hybrid embeds agent nodes in workflow or vice versa.', + complexityLevel: 5, + nodes: [ + { id: 'classifier', agent: 'Plan', role: 'Classifier', prompt: 'Evaluate: is the path known and rules stable (workflow) or unknown/variable (agent)?' }, + { id: 'workflow', agent: 'agentic', role: 'Workflow', prompt: 'Predefined ordered execution for deterministic business logic.' }, + { id: 'agent-node', agent: 'agentic', role: 'Agent Node', prompt: 'Autonomous decision-making for bounded exploration and judgment.' }, + { id: 'compliance', agent: 'DeepReview', role: 'Compliance', prompt: 'Wrap agent outputs in workflow controls: compliance, approval, irreversible ops.', gate: true }, + ], + edges: [ + { from: 'classifier', to: 'workflow' }, + { from: 'classifier', to: 'agent-node' }, + { from: 'agent-node', to: 'compliance' }, + { from: 'workflow', to: 'compliance' }, + ], + }, +]; + +export default PATTERNS; From a009d659ae57e9391c013c9e4d6d4e0d0e6ac5c8 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:31:59 +0800 Subject: [PATCH 07/48] fix-unsafe-warnings --- .../src/computer_use/windows_ax_shortcuts.rs | 4 +- .../desktop/src/computer_use/windows_ax_ui.rs | 16 +- .../src/computer_use/windows_bg_input.rs | 20 +- .../src/computer_use/windows_capture.rs | 8 +- .../src/computer_use/windows_list_apps.rs | 8 +- .../desktop/src/computer_use/windows_msaa.rs | 16 +- .../src/computer_use/windows_wgc_capture.rs | 12 +- .../src/platform/evaluator/windows.rs | 4 +- .../agentic/agents/definitions/modes/team.rs | 1 + .../agents/definitions/subagents/acp_agent.rs | 79 +++ .../agents/definitions/subagents/mod.rs | 2 + .../assembly/core/src/agentic/agents/mod.rs | 3 +- .../src/agentic/agents/prompts/acp_agent.md | 17 + .../core/src/agentic/agents/registry/mod.rs | 5 + .../implementations/legion_control_tool.rs | 463 ++++++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 1 + .../execution/tool-provider-groups/src/lib.rs | 2 +- .../interfaces/acp/src/client/manager.rs | 28 ++ 19 files changed, 645 insertions(+), 46 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs diff --git a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs index 50f9b3e27a..c22d6378a2 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs @@ -44,7 +44,7 @@ const MENU_BAR_SEARCH_MAX_VISITED: usize = 4_000; unsafe fn build_shortcuts_cache_request( automation: &IUIAutomation, -) -> BitFunResult { +) -> BitFunResult { unsafe { let cache_req = automation .CreateCacheRequest() .map_err(|e| BitFunError::tool(format!("UI Automation CreateCacheRequest: {}.", e)))?; @@ -59,7 +59,7 @@ unsafe fn build_shortcuts_cache_request( let _ = cache_req.AddPattern(UIA_TogglePatternId); let _ = cache_req.SetTreeScope(TreeScope_Subtree); Ok(cache_req) -} +}} fn read_cached_name(element: &IUIAutomationElement) -> Option { unsafe { diff --git a/src/apps/desktop/src/computer_use/windows_ax_ui.rs b/src/apps/desktop/src/computer_use/windows_ax_ui.rs index bbcb411f84..0a9387810c 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_ui.rs @@ -139,7 +139,7 @@ fn localized_control_type_string(elem: &IUIAutomationElement) -> String { /// read, so the walk itself issues zero cross-process RPCs. unsafe fn build_cache_request( automation: &IUIAutomation, -) -> BitFunResult { +) -> BitFunResult { unsafe { let cache_req = automation .CreateCacheRequest() .map_err(|e| BitFunError::tool(format!("UI Automation CreateCacheRequest: {}.", e)))?; @@ -181,7 +181,7 @@ unsafe fn build_cache_request( } Ok(cache_req) -} +}} /// `BuildUpdatedCache` with a short retry loop. A single transient provider /// error (commonly `E_FAIL` / `0x80004005` from a control rebuilding its @@ -190,7 +190,7 @@ unsafe fn build_cache_request( pub(crate) unsafe fn build_updated_cache_with_retry( uncached: &IUIAutomationElement, cache_req: &IUIAutomationCacheRequest, -) -> BitFunResult { +) -> BitFunResult { unsafe { let mut attempt = 0u32; loop { match uncached.BuildUpdatedCache(cache_req) { @@ -213,7 +213,7 @@ pub(crate) unsafe fn build_updated_cache_with_retry( } } } -} +}} // ── Cached property readers ───────────────────────────────────────────────── // @@ -421,7 +421,7 @@ unsafe fn walk_tree_full( hwnd: windows::Win32::Foundation::HWND, max_elements: usize, max_depth: usize, -) -> BitFunResult<(String, Vec)> { +) -> BitFunResult<(String, Vec)> { unsafe { let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); let automation: IUIAutomation = CoCreateInstance(&CUIAutomation, None, CLSCTX_INPROC_SERVER) @@ -458,7 +458,7 @@ unsafe fn walk_tree_full( let tree_text = render_lines(&lines); Ok((tree_text, nodes)) -} +}} #[allow(clippy::too_many_arguments)] unsafe fn walk_cached_bounded( @@ -471,7 +471,7 @@ unsafe fn walk_cached_bounded( total: &mut usize, max_elements: usize, max_depth: usize, -) { +) { unsafe { if depth > max_depth || *total >= max_elements { return; } @@ -573,7 +573,7 @@ unsafe fn walk_cached_bounded( } } } -} +}} // ── Rendering ────────────────────────────────────────────────────────────── diff --git a/src/apps/desktop/src/computer_use/windows_bg_input.rs b/src/apps/desktop/src/computer_use/windows_bg_input.rs index 9c07c860c5..50f0c0c21a 100644 --- a/src/apps/desktop/src/computer_use/windows_bg_input.rs +++ b/src/apps/desktop/src/computer_use/windows_bg_input.rs @@ -438,7 +438,7 @@ fn post_char(hwnd: HWND, ch: char) -> BitFunResult<()> { /// and is visually hidden (not rendered) while still receiving messages, so the /// brief foreground swap in the cloaked-injection path is invisible to the /// user. Best-effort; returns whether the attribute was set. -unsafe fn set_cloak(h: HWND, on: bool) -> bool { +unsafe fn set_cloak(h: HWND, on: bool) -> bool { unsafe { let v: BOOL = if on { TRUE } else { FALSE }; DwmSetWindowAttribute( h, @@ -447,13 +447,13 @@ unsafe fn set_cloak(h: HWND, on: bool) -> bool { std::mem::size_of::() as u32, ) .is_ok() -} +}} /// Bring `target` to the foreground using the `AttachThreadInput` trick, which /// inherits the current foreground thread's FG-lock token so the swap is /// honored even on a foreground-locked session without UIAccess. Single attach, /// no retry loop — bounded. Returns whether `target` actually became foreground. -unsafe fn force_foreground_attached(target: HWND) -> bool { +unsafe fn force_foreground_attached(target: HWND) -> bool { unsafe { let cur = GetForegroundWindow(); if cur == target { return true; @@ -472,7 +472,7 @@ unsafe fn force_foreground_attached(target: HWND) -> bool { let _ = AttachThreadInput(my_tid, cur_tid, 0); } GetForegroundWindow() == target -} +}} /// Type `text` into a **background** target via real `SendInput` Unicode /// keystrokes, cloaked so the brief focus is hidden, then restore foreground. @@ -574,7 +574,7 @@ pub(super) fn inject_key_cloaked(hwnd: HWND, keycode: u16, modifiers: &[u16]) -> /// # Safety /// `process` must be a valid `HANDLE` (or the `GetCurrentProcess()` pseudo- /// handle) with `TOKEN_QUERY` access for `OpenProcessToken` to succeed. -unsafe fn process_integrity_rid(process: Handle) -> Option { +unsafe fn process_integrity_rid(process: Handle) -> Option { unsafe { let mut token: Handle = std::ptr::null_mut(); if OpenProcessToken(process, TOKEN_QUERY, &mut token) == 0 { return None; @@ -621,7 +621,7 @@ unsafe fn process_integrity_rid(process: Handle) -> Option { return None; } Some(*rid_ptr) -} +}} /// If posting `msg` from the current process to `hwnd` would be silently /// blocked by UIPI (User Interface Privilege Isolation), return a diagnostic @@ -888,7 +888,7 @@ fn vk_event(vk: u16, scan: u32, up: bool) -> Input { /// # Safety /// `SendInput` reads `ev.len()` `INPUT` records from `ev.as_ptr()`; every /// record is fully initialized above. `cbSize` is the true `size_of::`. -unsafe fn send_unicode(text: &str) -> BitFunResult<()> { +unsafe fn send_unicode(text: &str) -> BitFunResult<()> { unsafe { let mut ev: Vec = Vec::with_capacity(text.len() * 2); for u in text.encode_utf16() { ev.push(unicode_event(u, false)); @@ -909,14 +909,14 @@ unsafe fn send_unicode(text: &str) -> BitFunResult<()> { ))); } Ok(()) -} +}} /// Deliver a key + modifiers as a single `SendInput` burst: modifiers down, /// key down, key up, modifiers up (reverse). /// /// # Safety /// `SendInput` reads a fully-initialized `INPUT` array; `cbSize` is correct. -unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { +unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { unsafe { let mut ev: Vec = Vec::with_capacity(modifiers.len() * 2 + 2); for &m in modifiers { let m_scan = MapVirtualKeyW(m as u32, MAPVK_VK_TO_VSC); @@ -944,7 +944,7 @@ unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { ))); } Ok(()) -} +}} /// Fallback for [`inject_key_cloaked`] when foreground can't be obtained: post /// `WM_KEYDOWN` / `WM_KEYUP` to the window's queue (best-effort; may miss diff --git a/src/apps/desktop/src/computer_use/windows_capture.rs b/src/apps/desktop/src/computer_use/windows_capture.rs index f451381a67..e330a98951 100644 --- a/src/apps/desktop/src/computer_use/windows_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_capture.rs @@ -200,7 +200,7 @@ fn screenshot_window_via_wgc(hwnd: HWND) -> BitFunResult<(Vec, u32, u32)> { /// Works for UWP / DirectComposition surfaces that `PrintWindow` can't reach, /// as long as the window is on-screen and not occluded. Returns /// `(bgra_pixels, width, height)`. -unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { +unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { unsafe { let mut rect = RECT::default(); GetWindowRect(hwnd, &mut rect).map_err(|e| { BitFunError::service(format!("screen-region fallback: GetWindowRect failed: {e}")) @@ -270,7 +270,7 @@ unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32 )); } Ok((pixels, w, h)) -} +}} /// A captured window bitmap plus the screen-space geometry it maps to. /// @@ -307,7 +307,7 @@ pub(super) fn screenshot_window_capture(hwnd: HWND) -> BitFunResult BitFunResult { +unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult { unsafe { if hwnd.is_invalid() { return Err(BitFunError::service( "screenshot_window_bytes: invalid HWND", @@ -473,7 +473,7 @@ unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult Option { target_pid: u32, found: Option, } - unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL { + unsafe extern "system" fn cb(hwnd: HWND, lparam: LPARAM) -> BOOL { unsafe { let state = &mut *(lparam.0 as *mut FindState); if IsWindowVisible(hwnd).0 == 0 || IsIconic(hwnd).0 != 0 { return TRUE; @@ -107,7 +107,7 @@ pub(super) fn find_top_window_for_pid(pid: u32) -> Option { return windows::Win32::Foundation::FALSE; } TRUE - } + }} let mut state = FindState { target_pid: pid, found: None, @@ -130,7 +130,7 @@ fn enumerate_windows() -> Vec { state.into_inner().unwrap().windows } -unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { +unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { unsafe { let state = &*(lparam.0 as *const Mutex); // Skip invisible or minimized windows. @@ -162,7 +162,7 @@ unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { s.windows.push(WindowEntry { pid, title }); } TRUE -} +}} /// Resolve the full image path of `pid` and return its `.exe` basename. fn exe_basename_for_pid(pid: u32) -> Option { diff --git a/src/apps/desktop/src/computer_use/windows_msaa.rs b/src/apps/desktop/src/computer_use/windows_msaa.rs index 4b1b84bccc..425e983d97 100644 --- a/src/apps/desktop/src/computer_use/windows_msaa.rs +++ b/src/apps/desktop/src/computer_use/windows_msaa.rs @@ -113,7 +113,7 @@ unsafe fn walk_bounded( hwnd: isize, max_total: usize, max_depth: usize, -) -> BitFunResult> { +) -> BitFunResult> { unsafe { // BitFun is a Tauri GUI app; match the UIA path's apartment threading. let _ = CoInitializeEx(None, COINIT_APARTMENTTHREADED); @@ -157,7 +157,7 @@ unsafe fn walk_bounded( ); Ok(nodes) -} +}} /// Returns `true` when `hwnd` belongs to a LibreOffice / OpenOffice VCL window /// whose UIA provider is known to hang on `BuildUpdatedCache(Subtree)` or return @@ -209,7 +209,7 @@ unsafe fn walk( total: &mut usize, max_depth: usize, max_total: usize, -) { +) { unsafe { if depth >= max_depth || *total >= max_total { return; } @@ -363,7 +363,7 @@ unsafe fn walk( } } } -} +}} /// Construct a `VT_I4` VARIANT carrying `id` (used for `CHILDID_SELF` and child /// indices). The `windows` 0.61 crate exposes `VARIANT` as a `#[repr(C)]` struct @@ -372,22 +372,22 @@ unsafe fn walk( /// `VARIANT_0.Anonymous` field is `ManuallyDrop` inside a union; /// the borrow checker refuses to auto-`DerefMut` it for a write, so the /// `ManuallyDrop` is dereferenced explicitly. -unsafe fn child_id_variant(id: i32) -> VARIANT { +unsafe fn child_id_variant(id: i32) -> VARIANT { unsafe { let mut var = VARIANT::default(); (*var.Anonymous.Anonymous).vt = VT_I4; (*var.Anonymous.Anonymous).Anonymous.lVal = id; var -} +}} /// Read a `VT_I4` out of a VARIANT. `get_accRole` returns `VT_I4` in practice /// (custom roles may arrive as `VT_BSTR`, which we map to `None` = unknown). -unsafe fn variant_to_i32(v: &VARIANT) -> Option { +unsafe fn variant_to_i32(v: &VARIANT) -> Option { unsafe { if (*v.Anonymous.Anonymous).vt == VT_I4 { Some((*v.Anonymous.Anonymous).Anonymous.lVal) } else { None } -} +}} /// Map an MSAA role id to a `control_type` string matching the UIA path. For /// roles not in this list we emit `Role_` so the agent still sees something diff --git a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs index c601c37b83..b9f150a481 100644 --- a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs @@ -98,7 +98,7 @@ pub(super) fn capture_window_bgra(hwnd: HWND) -> BitFunResult<(Vec, u32, u32 } } -unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { +unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { unsafe { let mut device: Option = None; let mut context: Option = None; let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; @@ -137,9 +137,9 @@ unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceConte BitFunError::service("D3D11CreateDevice returned null context".to_string()) })?; Ok((device, context)) -} +}} -unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { +unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { unsafe { let dxgi_device: IDXGIDevice = d3d_device .cast() .map_err(|e| BitFunError::service(format!("IDXGIDevice cast: {e}")))?; @@ -148,13 +148,13 @@ unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult BitFunResult<(Vec, u32, u32)> { +) -> BitFunResult<(Vec, u32, u32)> { unsafe { let surface = frame .Surface() .map_err(|e| BitFunError::service(format!("WGC frame Surface: {e}")))?; @@ -215,6 +215,6 @@ unsafe fn copy_frame_to_bgra( d3d_context.Unmap(&staging, 0); Ok((pixels, width, height)) -} +}} const D3D11_SDK_VERSION: u32 = 7; diff --git a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs index 7e3ee37cb0..98592c7489 100644 --- a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs @@ -170,7 +170,7 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand } } -unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { +unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { unsafe { let handler: ICoreWebView2WebMessageReceivedEventHandler = WebMessageReceivedHandler.into(); let mut token = std::mem::zeroed(); webview @@ -183,7 +183,7 @@ unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDri std::mem::forget(handler); Ok(()) -} +}} fn parse_message_payload(message: &str) -> Option { if let Ok(inner) = serde_json::from_str::(message) { diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 47c119d777..9c880f5245 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -46,6 +46,7 @@ impl TeamMode { "get_goal".to_string(), "create_goal".to_string(), "update_goal".to_string(), + "LegionControl".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs new file mode 100644 index 0000000000..190aacf892 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -0,0 +1,79 @@ +//! ACP bridge agent — an AgentRegistry entry for every configured ACP client. +//! +//! Each ACP client (OpenCode, Claude Code, CodeBuddy, etc.) is represented as a +//! `SubAgent` so it appears in the agent selector and can be targeted by +//! `SessionControl` / `SessionMessage` for legion orchestration. + +use crate::agentic::agents::{Agent, UserContextPolicy}; +use async_trait::async_trait; +use bitfun_agent_tools::build_acp_external_agent_tool_name; + +/// A thin Agent wrapper around a single ACP client config. +#[allow(dead_code)] +pub struct AcpAgent { + agent_id: String, + tool_name: String, + display_name: String, + default_tools: Vec, +} + +impl AcpAgent { + pub fn new(client_id: String, display_name: String) -> Self { + let agent_id = Self::agent_id_for(&client_id); + let tool_name = build_acp_external_agent_tool_name(&client_id); + Self { + default_tools: vec![ + tool_name.clone(), + "Read".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "LS".to_string(), + ], + agent_id, + tool_name, + display_name, + } + } + + /// The agent registry id: `acp__` + pub fn agent_id_for(client_id: &str) -> String { + format!("acp__{client_id}") + } +} + +#[async_trait] +impl Agent for AcpAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + &self.agent_id + } + + fn name(&self) -> &str { + &self.display_name + } + + fn description(&self) -> &str { + "External ACP agent — delegates tasks to a connected ACP-compatible client." + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "acp_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn user_context_policy(&self) -> UserContextPolicy { + UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs index 37309c8e3f..3b18284957 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ +mod acp_agent; mod computer_use; mod explore; mod file_finder; mod general_purpose; mod research_specialist; +pub use acp_agent::AcpAgent; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 9ca3b39c2b..a3a443a630 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -35,7 +35,8 @@ pub use definitions::review::{ }; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, + AcpAgent, ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, + ResearchSpecialistAgent, }; use indexmap::IndexMap; pub use prompt_builder::{ diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md new file mode 100644 index 0000000000..08d0629d7b --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md @@ -0,0 +1,17 @@ +You are a bridge to an external ACP agent running inside BitFun. A commander has delegated a task to you. Your job is to forward the task through your ACP tool and return the result, nothing more. + +## How You Work + +1. Read the task that was sent to you via SessionMessage. +2. Call your ACP prompt tool with the task as the `prompt` parameter. +3. Return the ACP agent's response exactly as received — do not summarise, reinterpret, or embellish. +4. If the ACP tool returns an error, report the error with the original task context so the commander can decide how to proceed. + +## Constraints + +- You do NOT have file write or edit capabilities by default. Your only execution tool is the ACP bridge. +- Do NOT ask the user questions. The commander is your only audience. +- Be concise. The commander is managing many agents and needs clear, direct responses. +- Do NOT pretend to perform work that should be delegated through the ACP tool. + +{LANGUAGE_PREFERENCE} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 9de2e41697..188e156b59 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -116,6 +116,11 @@ impl AgentRegistry { .and_then(|entries| entries.get(agent_type).cloned()) } + /// Remove an agent entry by id (e.g. when an ACP client is disabled/removed). + pub fn unregister_agent(&self, id: &str) { + self.write_agents().remove(id); + } + /// Get a agent by ID (searches all categories including hidden) pub fn get_agent( &self, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs new file mode 100644 index 0000000000..d7e7561016 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -0,0 +1,463 @@ +//! LegionControl tool — load and instantiate legion templates. +//! +//! Reads `.bitfun/config/legions/.json`, topologically sorts nodes by +//! edges, creates each node via `SessionControl` path, and returns the +//! created session list. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_runtime_ports::AgentSessionCreateRequest; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; + +/// JSON format of a legion template file. +#[derive(Debug, Deserialize)] +struct LegionTemplate { + id: String, + name: String, + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +struct LegionNode { + id: String, + agent: String, + #[serde(default)] + role: String, + #[serde(default)] + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct LegionEdge { + from: String, + to: String, + #[serde(default)] + condition: String, +} + +#[derive(Debug, Deserialize)] +struct LegionControlInput { + action: String, + legion_id: String, + #[serde(default)] + workspace: Option, +} + +pub struct LegionControlTool; + +impl LegionControlTool { + pub fn new() -> Self { + Self + } + + fn config_dir(&self) -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".bitfun").join("config").join("legions") + } + + fn legion_path(&self, legion_id: &str) -> PathBuf { + self.config_dir().join(format!("{}.json", legion_id)) + } +} + +#[async_trait] +impl Tool for LegionControlTool { + fn name(&self) -> &str { + "LegionControl" + } + + async fn description(&self) -> BitFunResult { + Ok(concat!( + "Load and instantiate a legion template.\n\n", + "Actions:\n", + "- load: Read a legion template from `.bitfun/config/legions/.json`, ", + "topologically sort nodes by edges, create each node as a new agent session ", + "via SessionControl, and return the created session list.\n", + "- list: List available legion templates.\n\n", + "Parameters:\n", + "- action: \"load\" or \"list\"\n", + "- legion_id: template id (required for load)\n", + "- workspace: override workspace path (optional, defaults to current workspace)", + ) + .to_string()) + } + + fn short_description(&self) -> String { + "Load and instantiate legion templates with automatic topology-sorted node creation." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Expanded + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["load", "list"] + }, + "legion_id": { + "type": "string", + "description": "Legion template id (required for load action)" + }, + "workspace": { + "type": "string", + "description": "Override workspace path" + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match serde_json::from_value::(input.clone()) { + Ok(params) => { + if params.action == "load" && params.legion_id.is_empty() { + return ValidationResult { + result: false, + message: Some("legion_id is required for load action".to_string()), + error_code: None, + meta: None, + }; + } + ValidationResult::default() + } + Err(e) => ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", e)), + error_code: None, + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(Value::as_str) + .unwrap_or("?"); + let legion_id = input + .get("legion_id") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("LegionControl {} {}", action, legion_id) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let params: LegionControlInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let runtime = CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) + .map_err(BitFunError::tool)?; + + match params.action.as_str() { + "list" => self.list_legions().await, + "load" => { + self.load_and_instantiate(¶ms, context, &runtime) + .await + } + _ => Err(BitFunError::tool(format!( + "Unknown action: {}. Supported: load, list", + params.action + ))), + } + } +} + +impl LegionControlTool { + async fn list_legions(&self) -> BitFunResult> { + let dir = self.config_dir(); + let mut names: Vec = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_string()); + } + } + } + } + + let result_text = if names.is_empty() { + format!( + "No legion templates found in {}", + dir.display() + ) + } else { + let mut lines = vec!["Available legion templates:".to_string()]; + for name in &names { + lines.push(format!("- {}", name)); + } + lines.join("\n") + }; + + Ok(vec![ToolResult::Result { + data: json!({ "legions": names }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]) + } + + async fn load_and_instantiate( + &self, + params: &LegionControlInput, + context: &ToolUseContext, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + ) -> BitFunResult> { + let path = self.legion_path(¶ms.legion_id); + let content = std::fs::read_to_string(&path).map_err(|e| { + BitFunError::tool(format!( + "Failed to read legion template {}: {}", + path.display(), + e + )) + })?; + + let template: LegionTemplate = serde_json::from_str(&content).map_err(|e| { + BitFunError::tool(format!("Invalid legion template: {}", e)) + })?; + + if template.nodes.is_empty() { + return Err(BitFunError::tool("Legion template has no nodes".to_string())); + } + + // Determine workspace + let workspace = params + .workspace + .clone() + .or_else(|| context.workspace_root().map(|p| p.to_string_lossy().to_string())) + .ok_or_else(|| BitFunError::tool("workspace is required".to_string()))?; + + // Topological sort + let sorted_ids = topological_sort(&template.nodes, &template.edges)?; + + // Build node lookup + let node_map: HashMap<&str, &LegionNode> = + template.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + + // Create sessions in topological order + let mut created_sessions: Vec = Vec::new(); + let mut session_lines: Vec = vec![format!( + "## Legion: {} ({})", + template.name, template.id + )]; + session_lines.push(format!("Workspace: {}", workspace)); + session_lines.push(String::new()); + + // Batch create: group independent nodes per topological layer + let layers = build_topological_layers(&sorted_ids, &template.edges); + for (layer_idx, layer) in layers.iter().enumerate() { + if layer.len() > 1 { + session_lines.push(format!( + "**Layer {} ({} parallel nodes):**", + layer_idx + 1, + layer.len() + )); + } + + for node_id in layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + + let session_name = if node.role.is_empty() { + format!("{}-{}", template.name, node.id) + } else { + format!("{}-{}", template.name, node.role) + }; + + let session = runtime + .create_session(AgentSessionCreateRequest { + session_name, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + metadata: { + let mut meta = serde_json::Map::new(); + meta.insert( + "legionId".to_string(), + json!(template.id), + ); + meta.insert( + "legionNodeId".to_string(), + json!(node.id), + ); + meta.insert( + "legionRole".to_string(), + json!(node.role), + ); + meta + }, + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; + + let entry = json!({ + "node_id": node.id, + "role": node.role, + "agent_type": node.agent, + "session_id": session.session_id, + "session_name": session.session_name, + }); + created_sessions.push(entry); + + session_lines.push(format!( + "- **{}** ({}) → session `{}` (agent: {})", + node.role, node.id, session.session_id, node.agent + )); + } + + if layer.len() > 1 { + session_lines.push(String::new()); + } + } + + // Append edge structure + if !template.edges.is_empty() { + session_lines.push(String::from("### Edges")); + for edge in &template.edges { + let cond = if edge.condition.is_empty() { + String::new() + } else { + format!(" [condition: {}]", edge.condition) + }; + session_lines.push(format!("- {} → {}{}", edge.from, edge.to, cond)); + } + } + + Ok(vec![ToolResult::Result { + data: json!({ + "legion_id": template.id, + "legion_name": template.name, + "nodes_created": created_sessions.len(), + "sessions": created_sessions, + }), + result_for_assistant: Some(session_lines.join("\n")), + image_attachments: None, + }]) + } +} + +/// Topological sort: nodes with no incoming edges first. +fn topological_sort( + nodes: &[LegionNode], + edges: &[LegionEdge], +) -> BitFunResult> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); + + for node in nodes { + in_degree.entry(node.id.as_str()).or_insert(0); + adjacency.entry(node.id.as_str()).or_default(); + } + + for edge in edges { + adjacency + .entry(edge.from.as_str()) + .or_default() + .push(edge.to.as_str()); + *in_degree.entry(edge.to.as_str()).or_insert(0) += 1; + } + + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut sorted: Vec = Vec::new(); + while let Some(node) = queue.pop_front() { + sorted.push(node.to_string()); + if let Some(neighbors) = adjacency.get(node) { + for &neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbor); + } + } + } + } + } + + if sorted.len() != nodes.len() { + let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + let sorted_set: HashSet<&str> = sorted.iter().map(|s| s.as_str()).collect(); + let missing: Vec<_> = node_ids.difference(&sorted_set).collect(); + return Err(BitFunError::tool(format!( + "Cyclic dependency detected in legion edges. Unresolved nodes: {:?}", + missing + ))); + } + + Ok(sorted) +} + +/// Group topologically sorted nodes into layers where each layer can execute in parallel. +fn build_topological_layers( + sorted_ids: &[String], + edges: &[LegionEdge], +) -> Vec> { + let mut layers: Vec> = Vec::new(); + let mut assigned: HashMap<&str, usize> = HashMap::new(); + + for id in sorted_ids { + // Place node one layer after all its predecessors. + // Nodes with no predecessors land on layer 0. + let max_pred_layer = edges + .iter() + .filter(|e| e.to == *id) + .filter_map(|e| assigned.get(e.from.as_str())) + .max() + .copied(); + + let layer = match max_pred_layer { + Some(max_layer) => max_layer + 1, + None => 0, + }; + while layers.len() <= layer { + layers.push(Vec::new()); + } + layers[layer].push(id.clone()); + assigned.insert(id.as_str(), layer); + } + + layers.retain(|l| !l.is_empty()); + layers +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 81453cb82c..79d39ed797 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -26,6 +26,7 @@ pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; +pub mod legion_control_tool; pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; @@ -67,6 +68,7 @@ pub use get_time_tool::GetTimeTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; +pub use legion_control_tool::LegionControlTool; pub use ls_tool::LSTool; pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index f1e92fc35c..6627aa6441 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -65,6 +65,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "ControlHub" => Some(Arc::new(ControlHubTool::new())), "ComputerUse" => Some(Arc::new(ComputerUseTool::new())), "Playbook" => Some(Arc::new(PlaybookTool::new())), + "LegionControl" => Some(Arc::new(LegionControlTool::new())), _ => None, } } diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 8b0608e381..9ed577a8d1 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -161,7 +161,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { provider_id: "core.session", feature_groups: CORE_SESSION_FEATURE_GROUPS, - tool_names: &["SessionControl", "SessionMessage", "SessionHistory", "Cron"], + tool_names: &["SessionControl", "SessionMessage", "SessionHistory", "Cron", "LegionControl"], }, ToolProviderGroupPlan { provider_id: "core.integration", diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 169e2258b6..3b313119bf 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1507,6 +1507,34 @@ impl AcpClientService { debug!("Registering ACP client tool: name={}", tool.name()); registry.register_tool(tool); } + drop(registry); + + // Also register each ACP client as a SubAgent in the global AgentRegistry + // so they appear in the agent selector and can be targeted by + // SessionControl / SessionMessage for legion orchestration. + let agent_registry = + bitfun_core::agentic::agents::get_agent_registry(); + for (client_id, config) in configs.iter().filter(|(_, c)| c.enabled) { + let agent_id = + bitfun_core::agentic::agents::AcpAgent::agent_id_for( + client_id, + ); + // Clean up any previous registration for this client + agent_registry.unregister_agent(&agent_id); + let agent = Arc::new( + bitfun_core::agentic::agents::AcpAgent::new( + client_id.clone(), + config.name.clone().unwrap_or_else(|| client_id.clone()), + ), + ); + agent_registry.register_agent( + agent, + bitfun_core::agentic::agents::AgentCategory::SubAgent, + bitfun_core::agentic::agents::AgentSource::Builtin, + Some(bitfun_core::agentic::agents::SubAgentSource::Builtin), + None, + ); + } } async fn handle_permission_request( From 432777c1083aaaab0f6bc4a8ce2c4224d0dc0da2 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:39:18 +0800 Subject: [PATCH 08/48] fix-legion-list --- .../src/agentic/tools/implementations/legion_control_tool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index d7e7561016..e28cfa9824 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -50,6 +50,7 @@ struct LegionEdge { #[derive(Debug, Deserialize)] struct LegionControlInput { action: String, + #[serde(default)] legion_id: String, #[serde(default)] workspace: Option, From 08e0701c768e11247d355e6b5952d698f5b3fe64 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:52:44 +0800 Subject: [PATCH 09/48] fix-legion-topo-skip-conditional-edges --- .../agentic/tools/implementations/legion_control_tool.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index e28cfa9824..53d1c86cd5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -389,6 +389,11 @@ fn topological_sort( } for edge in edges { + // Skip conditional edges (fail/retry) — they are runtime routing, + // not compile-time dependencies for the DAG. + if !edge.condition.is_empty() { + continue; + } adjacency .entry(edge.from.as_str()) .or_default() @@ -443,7 +448,7 @@ fn build_topological_layers( // Nodes with no predecessors land on layer 0. let max_pred_layer = edges .iter() - .filter(|e| e.to == *id) + .filter(|e| e.to == *id && e.condition.is_empty()) .filter_map(|e| assigned.get(e.from.as_str())) .max() .copied(); From 3ac6d325a9db860f26e24d2aaed066c4e3f6ceb6 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:58:15 +0800 Subject: [PATCH 10/48] acp-mode-and-legion-topo-fixes --- src/crates/interfaces/acp/src/client/manager.rs | 4 ++-- .../src/app/scenes/agents/hooks/useAgentsList.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 3b313119bf..3764fa141b 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1529,9 +1529,9 @@ impl AcpClientService { ); agent_registry.register_agent( agent, - bitfun_core::agentic::agents::AgentCategory::SubAgent, + bitfun_core::agentic::agents::AgentCategory::Mode, bitfun_core::agentic::agents::AgentSource::Builtin, - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin), + None, None, ); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 9732508a8a..2f997b5718 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -134,7 +134,7 @@ export function useAgentsList({ loadDefaultReviewTeamDefinition().catch(() => undefined), ]); - // Fetch enabled ACP external agents for the core zone. + // Fetch enabled ACP external agents for core zone metadata enrichment. let acpClients: AcpClientInfo[] = []; try { acpClients = (await ACPClientAPI.getClients()).filter((c) => c.enabled); @@ -203,9 +203,14 @@ export function useAgentsList({ }), ); - const acpAgents: AgentWithCapabilities[] = acpClients.map((client): AgentWithCapabilities => { + const modeAgentIds = new Set(modeAgents.map((a) => a.id)); + const acpAgents: AgentWithCapabilities[] = acpClients + .filter((client) => !modeAgentIds.has(`${ACP_CORE_AGENT_PREFIX}${client.id}`)) + .map((client): AgentWithCapabilities => { const agentId = `${ACP_CORE_AGENT_PREFIX}${client.id}`; const displayName = client.name || client.id; + const acpToolName = `${ACP_CORE_AGENT_PREFIX}${client.id}__prompt`; + const defaultTools = [acpToolName, 'Read', 'Grep', 'Glob', 'LS']; return enrichCapabilities({ key: `acp::${client.id}`, id: agentId, @@ -215,8 +220,8 @@ export function useAgentsList({ : `External ACP agent — ${client.status}`, isReadonly: client.readonly, isReview: false, - toolCount: 0, - defaultTools: [], + toolCount: defaultTools.length, + defaultTools, defaultEnabled: client.enabled, effectiveEnabled: client.enabled, source: 'builtin' as AgentSource, From 44b5c1690437fdc3b4ae3bb84cfe6e03ac1a43e5 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:25:41 +0800 Subject: [PATCH 11/48] fix-acp-duplicate-tool-name --- .../src/agentic/agents/definitions/subagents/acp_agent.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs index 190aacf892..58452516d6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -6,13 +6,11 @@ use crate::agentic::agents::{Agent, UserContextPolicy}; use async_trait::async_trait; -use bitfun_agent_tools::build_acp_external_agent_tool_name; /// A thin Agent wrapper around a single ACP client config. #[allow(dead_code)] pub struct AcpAgent { agent_id: String, - tool_name: String, display_name: String, default_tools: Vec, } @@ -20,17 +18,17 @@ pub struct AcpAgent { impl AcpAgent { pub fn new(client_id: String, display_name: String) -> Self { let agent_id = Self::agent_id_for(&client_id); - let tool_name = build_acp_external_agent_tool_name(&client_id); Self { + // ACP prompt tool is registered in the global tool registry by + // register_configured_tools() — do NOT add it to default_tools + // here, or the tool name will appear twice in the model manifest. default_tools: vec![ - tool_name.clone(), "Read".to_string(), "Grep".to_string(), "Glob".to_string(), "LS".to_string(), ], agent_id, - tool_name, display_name, } } From d805b7d56361797a34d73c93084d0dd1c168e90e Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:36:13 +0800 Subject: [PATCH 12/48] a --- .../core/src/agentic/agents/definitions/subagents/acp_agent.rs | 2 +- src/web-ui/src/locales/en-US/scenes/agents.json | 2 +- src/web-ui/src/locales/zh-CN/scenes/agents.json | 2 +- src/web-ui/src/locales/zh-TW/scenes/agents.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs index 58452516d6..5203ed90a6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -54,7 +54,7 @@ impl Agent for AcpAgent { } fn description(&self) -> &str { - "External ACP agent — delegates tasks to a connected ACP-compatible client." + "ACP agent" } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 4065d252d2..64330abc8e 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -43,7 +43,7 @@ "role": "Desktop automation agent" }, "acpExternal": { - "role": "External ACP agent — {{name}}" + "role": "ACP Agent" } } }, diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index c4da2538e7..faee35c13d 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -43,7 +43,7 @@ "role": "电脑操作智能体" }, "acpExternal": { - "role": "外部 ACP 智能体 — {{name}}" + "role": "ACP智能体" } } }, diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index d2d24444fe..a18b7b8b80 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -43,7 +43,7 @@ "role": "電腦操作智能體" }, "acpExternal": { - "role": "外部 ACP 智能體 — {{name}}" + "role": "ACP智能體" } } }, From 46d8539a2c591a548546d527a2320b6397c4297b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:40:45 +0800 Subject: [PATCH 13/48] feat: LegionControl auto-send + Gate Loop Protocol + behavior guard stale-strategy detection --- .../src/agentic/agents/prompts/team_mode.md | 55 ++++++++++++++ .../implementations/legion_control_tool.rs | 75 ++++++++++++++++++- .../core/src/agentic/tools/post_call_hooks.rs | 73 +++++++++++++++++- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index ac7611052f..0be0119c22 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md @@ -20,6 +20,8 @@ All implementation, file operations, commands, and code changes MUST be delegate | `SessionHistory(session_id)` | Export a legion member's transcript for review. Use before gate decisions. | | `Task(subagent_type, prompt, run_in_background)` | Dispatch a sub-agent for focused, scoped work inside a single session. | | `get_goal` / `create_goal` / `update_goal` | Track campaign progress. Status flows: pending → in-progress → complete. Use `update_goal` to mark blocking when stuck. | +| `LegionControl(action:"load", legion_id:"")` | One-click deployment. Reads a legion template, topologically sorts nodes, creates all sessions, and returns the session list. Use this before manual SessionControl when a matching template exists. | +| `LegionControl(action:"list")` | List available legion templates. # The Three-Bee Atomic Unit @@ -33,6 +35,23 @@ These three bees communicate directly via SessionMessage. They form an internal # Deployment Protocol +## 0. Quick Deploy with LegionControl + +If a legion template matches the task, deploy it with one call: + +``` +LegionControl(action:"load", legion_id:"") +``` + +This creates all sessions in topological order and returns the session list with node IDs, roles, and agent types. You get back: +- All session IDs organized by topological layer +- Edge structure (who depends on whom) +- Which nodes are gates + +Then proceed to Step 3 (Fan-Out) — skip Steps 1-2. + +If no template matches, use Steps 1-2 below to build the legion manually. + ## 1. Task Decomposition Analyze the user's request. Break it into independent subtasks. Each subtask that is atomic (cannot be meaningfully split further) is assigned to one agent session. @@ -85,6 +104,42 @@ When all subtasks pass their gates, mark the campaign complete: update_goal(status:"complete") ``` +# Gate Loop Protocol + +Each legion layer follows a strict gate loop. The loop runs per-layer until every node in that layer passes its gate, then the next layer begins. + +**Loop mechanics per layer:** + +1. **Dispatch**: Send task via SessionMessage to each node in the current layer. Include acceptance criteria. All dispatches in a single message for parallelism. + +2. **Collect**: Wait for all nodes to reply. Each SessionMessage auto-returns when the agent completes. + +3. **Inspect**: Use SessionHistory to read each node's full transcript. Do NOT rely on the agent's summary alone. + +4. **Gate Decision** per node: + - PASS: Node met all acceptance criteria, output verified, no behavioral violations. + - FAIL: Node skipped verification, edited without reading, failed tests, or produced invalid output. + +5. **Correct or Proceed**: + - If any node FAILs: Send SessionMessage with `[CORRECTION] `. Return to step 2 for that node. + - If all nodes PASS: Proceed to the next layer. + +6. **Loop Counter**: Track retry count per node. If a node fails 3 corrections without improvement, do NOT retry the same approach. Instead: + - Re-decompose the subtask differently + - Assign a different agent type + - Escalate to a sub-legion (Step 6) + +**Gate rules applied during inspection:** +- Did the node read relevant files before editing? (SessionHistory check) +- Did the node verify output? (test/check commands in transcript) +- Did the node change strategy after repeated tool failures? +- Are all acceptance criteria met with evidence? + +**Examples of FAIL decisions:** +- Agent called Edit on `src/foo.rs` but never called Read on `src/foo.rs` → FAIL: "Read the file before editing" +- Agent claimed "tests pass" but transcript shows no test command → FAIL: "Run tests and show output" +- Agent called Grep 4 times with the same failing pattern → FAIL: "Strategy stale. Try a different search approach or read the directory listing first" + # Fractal Nesting Any agent session you create is also capable of creating its own sub-sessions. A legion member stuck on a complex problem can itself become a commander. This is not a bug — it is the design. Each level only cares about the level directly below it. The topology is self-similar at every scale. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 53d1c86cd5..55e9e88509 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -4,14 +4,16 @@ //! edges, creates each node via `SessionControl` path, and returns the //! created session list. -use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_runtime_ports::AgentSessionCreateRequest; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, DialogSubmissionPolicy, DialogTriggerSource, +}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -54,6 +56,9 @@ struct LegionControlInput { legion_id: String, #[serde(default)] workspace: Option, + /// When true, send each first-layer node its prompt as the initial task message. + #[serde(default)] + send_initial_message: bool, } pub struct LegionControlTool; @@ -362,6 +367,72 @@ impl LegionControlTool { } } + // Auto-send initial task messages to first-layer nodes + if params.send_initial_message && !layers.is_empty() { + let scheduler = get_global_scheduler() + .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; + let dialog_runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; + + let first_layer = &layers[0]; + session_lines.push(String::new()); + session_lines.push(format!( + "Sent initial tasks to {} first-layer node(s)", + first_layer.len() + )); + + for node_id in first_layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + let entry = created_sessions + .iter() + .find(|e| e["node_id"].as_str() == Some(node.id.as_str())); + let session_id = match entry.and_then(|e| e["session_id"].as_str()) { + Some(sid) => sid.to_string(), + None => continue, + }; + + let task_message = if node.prompt.is_empty() { + format!("Execute your role: {}", node.role) + } else { + node.prompt.clone() + }; + + let _ = dialog_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id, + message: task_message.clone(), + original_message: None, + turn_id: None, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route: None, + prepended_reminders: vec![], + attachments: vec![], + metadata: None, + }) + .await; + + session_lines.push(format!( + "- Sent to **{}**: {}", + node.role, + if node.prompt.len() > 80 { + format!("{}...", &node.prompt[..77]) + } else { + node.prompt.clone() + } + )); + } + } + Ok(vec![ToolResult::Result { data: json!({ "legion_id": template.id, diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index 09923908db..b69cf9adf5 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -10,6 +10,21 @@ use bitfun_agent_runtime::post_call_hooks::{ run_successful_tool_post_call_hooks, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::Value; +use std::collections::HashMap; +use std::sync::Mutex; + +/// Tracks consecutive same-tool calls per session for stale-strategy detection. +static STALE_TRACKER: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, Default)] +struct StaleToolState { + last_tool: String, + consecutive_count: u32, +} + +/// Max consecutive same-tool calls before abort. +const STALE_STRATEGY_THRESHOLD: u32 = 3; struct CorePostCallHookExecutor; @@ -25,10 +40,62 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec fn behavior_guard( &mut self, - _tool_name: &str, - _input: &Value, - _context: &ToolUseContext, + tool_name: &str, + input: &Value, + context: &ToolUseContext, ) -> HookResult { + // 1. Stale strategy: detect repeated same-tool calls (dead-loop pattern). + if let Some(session_id) = &context.session_id { + if let Ok(mut tracker) = STALE_TRACKER.lock() { + let entry = tracker + .entry(session_id.clone()) + .or_insert_with(StaleToolState::default); + if entry.last_tool == tool_name { + entry.consecutive_count += 1; + } else { + entry.last_tool = tool_name.to_string(); + entry.consecutive_count = 1; + } + if entry.consecutive_count >= STALE_STRATEGY_THRESHOLD { + return HookResult::Abort { + reason: format!( + "Tool '{}' called {} consecutive times without strategy change", + tool_name, entry.consecutive_count + ), + fix_instruction: format!( + "Stop retrying {tool}. Read the previous results, identify the root failure cause, and choose a different approach. Do not call {tool} again without a changed strategy.", + tool = tool_name + ), + max_retries: 0, + }; + } + } + } + + // 2. File read-before-edit: warn when editing without prior context. + // Full enforcement requires session-level file-read state tracking; + // this guard provides a Should-level prompt injection point. + if matches!( + tool_name, + "Edit" | "Write" | "Delete" | "edit_file" | "write_file" | "delete_file" + ) { + if let Some(file_path) = input + .get("file_path") + .or_else(|| input.get("path")) + .and_then(Value::as_str) + { + // Guard: editing a file without a known read in this turn + // is a behavior smell — inject a soft constraint via + // the result-for-assistant path rather than aborting. + let _ = file_path; // reserved for read-state cross-check + } + } + + // 3. Exit code check: requires post-execution result inspection. + // Implemented at the tool pipeline level (exec_command.rs response + // handling), not in the post-call hook which fires before result + // inspection. Hook here serves as a future integration point. + HookResult::Continue } } From 23af21c14b2264cb277ddc376f89145af6d12a8b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:43:37 +0800 Subject: [PATCH 14/48] fix: LegionControl send_initial_message scope and metadata type --- .../src/agentic/tools/implementations/legion_control_tool.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 55e9e88509..52eb0ef93e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -369,6 +369,8 @@ impl LegionControlTool { // Auto-send initial task messages to first-layer nodes if params.send_initial_message && !layers.is_empty() { + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; let dialog_runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( @@ -417,7 +419,7 @@ impl LegionControlTool { reply_route: None, prepended_reminders: vec![], attachments: vec![], - metadata: None, + metadata: serde_json::Map::new(), }) .await; From d70d189750ebc0a555116efd24fe0da242956585 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:04:01 +0800 Subject: [PATCH 15/48] fix: replace deprecated theme color tokens in LegionCard.scss --- .../src/app/scenes/agents/components/LegionCard.scss | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss index 957aaf6cfb..925e15e3ea 100644 --- a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss @@ -5,12 +5,12 @@ gap: 10px; padding: 16px; border-radius: 10px; - background: var(--color-surface-card); - border: 1px solid var(--color-border-subtle); + background: var(--element-bg-soft); + border: 1px solid var(--border-subtle); transition: border-color 0.15s, box-shadow 0.15s; &:hover { - border-color: var(--color-border-accent); + border-color: var(--border-accent); box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); } @@ -28,8 +28,8 @@ width: 36px; height: 36px; border-radius: 8px; - background: var(--color-surface-accent); - color: var(--color-text-accent); + background: var(--color-overlay-white-12); + color: var(--color-accent-500); display: flex; align-items: center; justify-content: center; @@ -94,6 +94,6 @@ align-items: center; gap: 4px; font-size: 11px; - color: var(--color-text-tertiary); + color: var(--color-text-muted); } } From e47222c429b0aa88a48602d161c29474a4f29e3a Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:11:26 +0800 Subject: [PATCH 16/48] fix: add default impl for behavior_guard() in trait for backward compatibility --- .../execution/agent-runtime/src/post_call_hooks.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index d1566f24fc..c3efae29a4 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -174,10 +174,12 @@ pub trait SuccessfulToolPostCallHookExecutor { fn behavior_guard( &mut self, - tool_name: &str, - input: &Value, - context: &C, - ) -> HookResult; + _tool_name: &str, + _input: &Value, + _context: &C, + ) -> HookResult { + HookResult::Continue + } } pub fn run_successful_tool_post_call_hooks( From b0db4612051cf5b7d03e0a870aa015ccf6dd64aa Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:47:56 +0800 Subject: [PATCH 17/48] chore: commit pending fixes - path security, rollback, i18n integration, stale tracker cleanup --- .../core/src/agentic/agents/team_presets.rs | 30 +++++-- .../implementations/legion_control_tool.rs | 68 +++++++++++++--- .../core/src/agentic/tools/post_call_hooks.rs | 12 +++ .../post_call_hook_execution_contracts.rs | 11 ++- .../agents/components/CreateLegionPage.tsx | 79 +++++++++++++------ .../scenes/agents/components/LegionCard.tsx | 25 +++--- .../api/service-api/LegionPresetAPI.ts | 45 +++++++++++ .../src/locales/en-US/scenes/agents.json | 28 +++++++ .../src/locales/zh-CN/scenes/agents.json | 28 +++++++ .../src/locales/zh-TW/scenes/agents.json | 28 +++++++ 10 files changed, 296 insertions(+), 58 deletions(-) create mode 100644 src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs index 067a911faa..7401f08518 100644 --- a/src/crates/assembly/core/src/agentic/agents/team_presets.rs +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -44,8 +44,26 @@ fn legions_dir() -> PathBuf { get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) } -fn preset_path(id: &str) -> PathBuf { - legions_dir().join(format!("{id}.json")) +fn preset_path(id: &str) -> Result { + validate_preset_id(id)?; + Ok(legions_dir().join(format!("{id}.json"))) +} + +/// Validate preset id to prevent path traversal. +/// Allowed characters: alphanumeric, underscore, and hyphen. +fn validate_preset_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("Legion preset id must not be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid legion preset id '{id}': only letters, digits, underscores, and hyphens are allowed" + )); + } + Ok(()) } fn ensure_legions_dir() -> std::io::Result<()> { @@ -78,7 +96,7 @@ pub fn list_presets() -> Result, String> { /// Load a single preset by id. pub fn get_preset(id: &str) -> Result { - let path = preset_path(id); + let path = preset_path(id)?; if !path.is_file() { return Err(format!("Legion preset '{id}' not found")); } @@ -90,7 +108,7 @@ pub fn get_preset(id: &str) -> Result { /// Create or overwrite a preset. pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; - let path = preset_path(&preset.id); + let path = preset_path(&preset.id)?; let raw = serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) @@ -98,7 +116,7 @@ pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { /// Update an existing preset (id must already exist). pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { - let path = preset_path(&preset.id); + let path = preset_path(&preset.id)?; if !path.is_file() { return Err(format!("Legion preset '{}' not found", preset.id)); } @@ -109,7 +127,7 @@ pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { /// Delete a preset by id. pub fn delete_preset(id: &str) -> Result<(), String> { - let path = preset_path(id); + let path = preset_path(id)?; if !path.is_file() { return Err(format!("Legion preset '{id}' not found")); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 52eb0ef93e..8176a7b5aa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -1,6 +1,6 @@ //! LegionControl tool — load and instantiate legion templates. //! -//! Reads `.bitfun/config/legions/.json`, topologically sorts nodes by +//! Reads `/legions/.json`, topologically sorts nodes by //! edges, creates each node via `SessionControl` path, and returns the //! created session list. @@ -8,11 +8,13 @@ use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler} use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::infrastructure::get_path_manager_arc; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionCreateRequest, DialogSubmissionPolicy, DialogTriggerSource, + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + DialogSubmissionPolicy, DialogTriggerSource, }; use serde::Deserialize; use serde_json::{json, Value}; @@ -69,8 +71,9 @@ impl LegionControlTool { } fn config_dir(&self) -> PathBuf { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - home.join(".bitfun").join("config").join("legions") + get_path_manager_arc() + .user_config_dir() + .join("legions") } fn legion_path(&self, legion_id: &str) -> PathBuf { @@ -88,7 +91,7 @@ impl Tool for LegionControlTool { Ok(concat!( "Load and instantiate a legion template.\n\n", "Actions:\n", - "- load: Read a legion template from `.bitfun/config/legions/.json`, ", + "- load: Read a legion template from the user config directory, ", "topologically sort nodes by edges, create each node as a new agent session ", "via SessionControl, and return the created session list.\n", "- list: List available legion templates.\n\n", @@ -273,8 +276,9 @@ impl LegionControlTool { let node_map: HashMap<&str, &LegionNode> = template.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); - // Create sessions in topological order + // Create sessions in topological order with rollback on failure let mut created_sessions: Vec = Vec::new(); + let mut created_session_ids: Vec<(String, String)> = Vec::new(); // (session_id, workspace) let mut session_lines: Vec = vec![format!( "## Legion: {} ({})", template.name, template.id @@ -284,7 +288,8 @@ impl LegionControlTool { // Batch create: group independent nodes per topological layer let layers = build_topological_layers(&sorted_ids, &template.edges); - for (layer_idx, layer) in layers.iter().enumerate() { + let mut creation_result: BitFunResult<()> = Ok(()); + 'creation: for (layer_idx, layer) in layers.iter().enumerate() { if layer.len() > 1 { session_lines.push(format!( "**Layer {} ({} parallel nodes):**", @@ -305,7 +310,7 @@ impl LegionControlTool { format!("{}-{}", template.name, node.role) }; - let session = runtime + let session = match runtime .create_session(AgentSessionCreateRequest { session_name, agent_type: node.agent.clone(), @@ -330,9 +335,18 @@ impl LegionControlTool { }, }) .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; + { + Ok(session) => session, + Err(error) => { + creation_result = Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(error), + )); + break 'creation; + } + }; + + created_session_ids + .push((session.session_id.clone(), workspace.clone())); let entry = json!({ "node_id": node.id, @@ -354,6 +368,38 @@ impl LegionControlTool { } } + // Rollback on failure: delete all successfully created sessions + if creation_result.is_err() { + let error_msg = creation_result.unwrap_err(); + let mut delete_errors: Vec = Vec::new(); + for (sid, ws) in &created_session_ids { + if let Err(e) = runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path: ws.clone(), + session_id: sid.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { + delete_errors.push(format!(" {}: {}", sid, e)); + } + } + let mut msg = format!( + "Failed to create all legion sessions. {} Created {} session(s) which were cleaned up.", + error_msg, + created_session_ids.len() + ); + if !delete_errors.is_empty() { + msg.push_str(&format!( + "\nNote: {} session(s) could not be cleaned up:\n{}", + delete_errors.len(), + delete_errors.join("\n") + )); + } + return Err(BitFunError::tool(msg)); + } + // Append edge structure if !template.edges.is_empty() { session_lines.push(String::from("### Edges")); diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index b69cf9adf5..0671052ea2 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -23,6 +23,18 @@ struct StaleToolState { consecutive_count: u32, } +/// Remove the stale-tracking entry for a given session. +/// +/// Callers (e.g. session lifecycle hooks) should invoke this when a session +/// is completed, deleted, or cancelled so that the global tracker does not +/// grow without bound. +#[allow(dead_code)] +pub(crate) fn remove_stale_tracker_for_session(session_id: &str) { + if let Ok(mut tracker) = STALE_TRACKER.lock() { + tracker.remove(session_id); + } +} + /// Max consecutive same-tool calls before abort. const STALE_STRATEGY_THRESHOLD: u32 = 3; diff --git a/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs index 48dfa0b84b..39ef9554f9 100644 --- a/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs @@ -1,6 +1,6 @@ use bitfun_agent_runtime::post_call_hooks::{ resolve_deep_review_shared_context_tool_use, run_successful_tool_post_call_hooks, - DeepReviewSharedContextToolUseFacts, SuccessfulToolPostCallHookExecutor, + DeepReviewSharedContextToolUseFacts, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::{json, Value}; use std::collections::HashMap; @@ -21,6 +21,15 @@ impl SuccessfulToolPostCallHookExecutor<&str> for RecordingExecutor { self.calls .push((tool_name.to_string(), input.clone(), (*context).to_string())); } + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &&str, + ) -> HookResult { + HookResult::Continue + } } #[test] diff --git a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx index a14ef9e928..5e4b32c7c3 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx @@ -1,10 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { ArrowLeft, GitBranch, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button, IconButton } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; import PATTERNS, { type LegionPatternNode, type LegionPatternEdge, } from '../data/orchestration-patterns'; +import { LegionPresetAPI } from '@/infrastructure/api/service-api/LegionPresetAPI'; import '../AgentsView.scss'; interface CreateLegionPageProps { @@ -12,23 +15,46 @@ interface CreateLegionPageProps { } const CreateLegionPage: React.FC = ({ onBack }) => { + const { t } = useTranslation('scenes/agents'); + const { success: notifySuccess, error: notifyError } = useNotification(); const [selectedPatternId, setSelectedPatternId] = useState(PATTERNS[0]?.id ?? ''); + const [saving, setSaving] = useState(false); - const patternOptions = useMemo(() => PATTERNS, []); - - const selectedPattern = useMemo( - () => patternOptions.find((p) => p.id === selectedPatternId) ?? null, - [patternOptions, selectedPatternId], - ); + const selectedPattern = PATTERNS.find((p) => p.id === selectedPatternId) ?? null; const handleSelectPattern = useCallback((id: string) => { setSelectedPatternId(id); }, []); - const handleSave = useCallback(() => { - // TODO: save legion preset to backend via Tauri command - onBack(); - }, [onBack]); + const handleSave = useCallback(async () => { + if (!selectedPattern || saving) return; + setSaving(true); + try { + await LegionPresetAPI.createPreset({ + id: selectedPattern.id, + name: selectedPattern.name, + description: selectedPattern.description, + nodes: selectedPattern.nodes.map((n) => ({ + id: n.id, + agent: n.agent, + role: n.role, + prompt: n.prompt, + gate: n.gate, + })), + edges: selectedPattern.edges.map((e) => ({ + from: e.from, + to: e.to, + condition: e.condition, + })), + }); + notifySuccess(`Legion preset "${selectedPattern.name}" saved`); + onBack(); + } catch (err) { + notifyError(`Failed to save legion preset: ${err}`); + } finally { + setSaving(false); + } + }, [selectedPattern, saving, onBack, notifySuccess, notifyError]); const renderNodeList = (nodes: LegionPatternNode[]) => (
@@ -39,7 +65,7 @@ const CreateLegionPage: React.FC = ({ onBack }) => { {node.role} {node.agent}
- {node.gate ? GATE : null} + {node.gate ? {t('legionPattern.gate')} : null} ))} @@ -69,27 +95,28 @@ const CreateLegionPage: React.FC = ({ onBack }) => {

- {selectedPattern ? selectedPattern.name : 'Choose a Pattern'} + {selectedPattern ? selectedPattern.name : t('legionPattern.choosePattern')}

{/* Pattern selector */}
-

Orchestration Patterns

+

{t('legionPattern.orchestrationPatterns')}

- {patternOptions.map((pattern) => ( + {PATTERNS.map((pattern) => (
handleSelectPattern(pattern.id)} role="button" tabIndex={0} + aria-pressed={pattern.id === selectedPatternId} onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)} data-testid="legion-pattern-option" data-pattern-id={pattern.id} @@ -105,19 +132,19 @@ const CreateLegionPage: React.FC = ({ onBack }) => { <> {/* Summary */}
-

Overview

+

{t('legionPattern.overview')}

{selectedPattern.description}

- Complexity: L{selectedPattern.complexityLevel} - {selectedPattern.nodes.length} nodes - {selectedPattern.edges.length} edges + {t('legionPattern.complexity', { level: selectedPattern.complexityLevel })} + {t('legionPattern.nodesCount', { count: selectedPattern.nodes.length })} + {t('legionPattern.edgesCount', { count: selectedPattern.edges.length })}
{/* Nodes */}

- Nodes ({selectedPattern.nodes.length}) + {t('legionPattern.nodes', { count: selectedPattern.nodes.length })}

{renderNodeList(selectedPattern.nodes)}
@@ -125,20 +152,20 @@ const CreateLegionPage: React.FC = ({ onBack }) => { {/* Edges */}

- Edges ({selectedPattern.edges.length}) + {t('legionPattern.edges', { count: selectedPattern.edges.length })}

{selectedPattern.edges.length > 0 ? renderEdgeList(selectedPattern.edges, selectedPattern.nodes) - :

No edges (self-contained agent)

} + :

{t('legionPattern.noEdges')}

}
{/* Actions */}
-
diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx index 498f8effb5..3b8c53d9c8 100644 --- a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { GitBranch, Users, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/component-library'; import type { LegionPattern } from '../data/orchestration-patterns'; import './LegionCard.scss'; @@ -10,24 +11,20 @@ interface LegionCardProps { onOpenDetails: (pattern: LegionPattern) => void; } -const COMPLEXITY_LABELS: Record = { - 1: 'L1', - 2: 'L2-L3', - 3: 'L3', - 4: 'L4', - 5: 'L5-L6', - 6: 'L6', - 7: 'L7', -}; - const LegionCard: React.FC = ({ pattern, index = 0, onOpenDetails, }) => { + const { t } = useTranslation('scenes/agents'); const gateNodes = pattern.nodes.filter((n) => n.gate).length; const openDetails = () => onOpenDetails(pattern); + const complexityLabel = + t(`legionPattern.complexityLabel.l${pattern.complexityLevel}`, { + defaultValue: `L${pattern.complexityLevel}`, + }); + return (
= ({ {pattern.name}
- {COMPLEXITY_LABELS[pattern.complexityLevel] ?? `L${pattern.complexityLevel}`} + {complexityLabel}
@@ -66,15 +63,15 @@ const LegionCard: React.FC = ({
- {pattern.nodes.length} nodes + {t('legionPattern.nodesCount', { count: pattern.nodes.length })} - {pattern.edges.length} edges + {t('legionPattern.edgesCount', { count: pattern.edges.length })} {gateNodes > 0 ? ( - {gateNodes} gate + {gateNodes} {t('legionPattern.meta.gate')} ) : null}
diff --git a/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts new file mode 100644 index 0000000000..dff346d9cd --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts @@ -0,0 +1,45 @@ +import { api } from './ApiClient'; + +export interface LegionPresetNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPresetEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPreset { + id: string; + name: string; + description: string; + nodes: LegionPresetNode[]; + edges: LegionPresetEdge[]; +} + +export const LegionPresetAPI = { + async listPresets(): Promise { + return api.invoke('list_legion_presets'); + }, + + async getPreset(id: string): Promise { + return api.invoke('get_legion_preset', { id }); + }, + + async createPreset(preset: LegionPreset): Promise { + return api.invoke('create_legion_preset', { preset }); + }, + + async updatePreset(preset: LegionPreset): Promise { + return api.invoke('update_legion_preset', { preset }); + }, + + async deletePreset(id: string): Promise { + return api.invoke('delete_legion_preset', { id }); + }, +}; diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 64330abc8e..e44bfabd97 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -273,5 +273,33 @@ "Debug": "Debug mode: systematically diagnose and fix errors in code", "Claw": "Claw mode: extract and integrate information from external sources", "Team": "Team mode: coordinate multiple agents to collaboratively complete complex tasks" + }, + "legionPattern": { + "back": "Back", + "choosePattern": "Choose a Pattern", + "orchestrationPatterns": "Orchestration Patterns", + "overview": "Overview", + "complexity": "Complexity: L{{level}}", + "nodesCount": "{{count}} nodes", + "edgesCount": "{{count}} edges", + "noEdges": "No edges (self-contained agent)", + "usePattern": "Use Pattern", + "gate": "GATE", + "nodes": "Nodes ({{count}})", + "edges": "Edges ({{count}})", + "meta": { + "nodes": "nodes", + "edges": "edges", + "gate": "gate" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index faee35c13d..3f500279dd 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -273,5 +273,33 @@ "Debug": "调试模式:系统性地诊断和修复代码中的错误", "Claw": "抓取模式:从外部源提取和整合信息", "Team": "团队模式:协调多个智能体协同完成复杂任务" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "选择模式", + "orchestrationPatterns": "编排模式", + "overview": "概览", + "complexity": "复杂度:L{{level}}", + "nodesCount": "{{count}} 个节点", + "edgesCount": "{{count}} 条边", + "noEdges": "无边关系(独立智能体)", + "usePattern": "使用此模式", + "gate": "关卡", + "nodes": "节点({{count}})", + "edges": "边({{count}})", + "meta": { + "nodes": "节点", + "edges": "边", + "gate": "关卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index a18b7b8b80..0a66398fca 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -273,5 +273,33 @@ "Debug": "除錯模式:系統性地診斷和修復程式碼中的錯誤", "Claw": "抓取模式:從外部來源提取和整合資訊", "Team": "團隊模式:協調多個智慧體協同完成複雜任務" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "選擇模式", + "orchestrationPatterns": "編排模式", + "overview": "概覽", + "complexity": "複雜度:L{{level}}", + "nodesCount": "{{count}} 個節點", + "edgesCount": "{{count}} 條邊", + "noEdges": "無邊關係(獨立智慧體)", + "usePattern": "使用此模式", + "gate": "關卡", + "nodes": "節點({{count}})", + "edges": "邊({{count}})", + "meta": { + "nodes": "節點", + "edges": "邊", + "gate": "關卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } From 42a8bd9f972960a662a3b2cd9fd5ea991cedc224 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:47:57 +0800 Subject: [PATCH 18/48] feat: expand SessionControl/SessionMessage/SessionHistory to Expanded exposure --- .../src/agentic/tools/implementations/session_control_tool.rs | 4 ++-- .../src/agentic/tools/implementations/session_history_tool.rs | 2 +- .../src/agentic/tools/implementations/session_message_tool.rs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index be53cad988..bfbaee38d6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -276,7 +276,7 @@ Arguments: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Expanded } fn input_schema(&self) -> Value { @@ -616,7 +616,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - loaded_deferred_tool_specs: Vec::new(), + unlocked_collapsed_tools: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index 67295625b6..af57e28495 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -106,7 +106,7 @@ Examples: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Expanded } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 550926116e..b6aa1d8ed4 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -298,7 +298,7 @@ Allowed agent types when creating a session: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Deferred + ToolExposure::Expanded } fn input_schema(&self) -> Value { @@ -723,7 +723,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - loaded_deferred_tool_specs: Vec::new(), + unlocked_collapsed_tools: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, From 7977e4f84bfa41ff32076a2af3bc3b0155dc06f2 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:49:23 +0800 Subject: [PATCH 19/48] fix: resolve pre-existing deprecation warnings --- .../services-integrations/src/mcp/protocol/client_info.rs | 2 -- .../src/mcp/protocol/transport_remote.rs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs index 157ca7d3c1..6abed44a1e 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/client_info.rs @@ -8,8 +8,6 @@ pub fn create_mcp_client_info( ) -> ClientInfo { ClientInfo::new( ClientCapabilities::builder() - .enable_roots() - .enable_sampling() .enable_elicitation() .build(), Implementation::new(client_name, client_version), diff --git a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs index 77e984a27e..faa0a13bec 100644 --- a/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs +++ b/src/crates/services/services-integrations/src/mcp/protocol/transport_remote.rs @@ -202,7 +202,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { } } - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(event_stream) } @@ -303,7 +303,7 @@ impl StreamableHttpClient for BitFunStreamableHttpClient { match content_type.as_deref() { Some(ct) if ct.as_bytes().starts_with(EVENT_STREAM_MIME_TYPE.as_bytes()) => { - let event_stream = SseStream::from_byte_stream(response.bytes_stream()).boxed(); + let event_stream = SseStream::from_bytes_stream(response.bytes_stream()).boxed(); Ok(StreamableHttpPostResponse::Sse(event_stream, session_id)) } Some(ct) if ct.as_bytes().starts_with(JSON_MIME_TYPE.as_bytes()) => { From 24503c358ae862918ef754eeca893ec3b7135eaa Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 16:58:34 +0800 Subject: [PATCH 20/48] feat: add session tools to Team mode and shared coding modes, dynamic agent_type --- .../agentic/agents/definitions/modes/team.rs | 6 +++ .../assembly/core/src/agentic/agents/mod.rs | 2 + .../core/src/agentic/agents/registry/query.rs | 17 +++++++++ .../implementations/session_control_tool.rs | 20 +++++++++- .../implementations/session_message_tool.rs | 37 +++++++++++-------- .../agent-runtime/src/session_control.rs | 19 ++-------- .../tests/session_control_contracts.rs | 2 +- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 75d45dd75a..47c119d777 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -40,6 +40,12 @@ impl TeamMode { "Git".to_string(), "ControlHub".to_string(), "GetFileDiff".to_string(), + "SessionControl".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), + "get_goal".to_string(), + "create_goal".to_string(), + "update_goal".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 7cb0ef2495..b55e436c32 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -126,6 +126,8 @@ pub fn shared_coding_mode_tools() -> Vec { "ReviewPlatform".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "SessionMessage".to_string(), + "SessionHistory".to_string(), ]; append_provider_group_tools(&mut tools, "core.canvas"); tools diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 3b5ce45d33..8334922745 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -123,6 +123,23 @@ impl AgentRegistry { result } + /// Return ids of all agents visible for session creation (modes + subagents). + pub async fn get_agent_ids_for_session_creation(&self) -> Vec { + self.ensure_user_custom_agents_loaded().await; + let map = self.read_agents(); + let mut ids: Vec = map + .values() + .filter(|e| { + matches!(e.category, AgentCategory::Mode | AgentCategory::SubAgent) + }) + .map(|e| e.agent.id().to_string()) + .collect(); + drop(map); + ids.sort(); + ids.dedup(); + ids + } + /// check if a subagent is readonly (used for TaskTool.is_concurrency_safe etc.) pub fn get_subagent_is_readonly(&self, id: &str) -> Option { if let Some(entry) = self.read_agents().get(id) { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index bfbaee38d6..ccbd32a8ea 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -302,8 +302,7 @@ Arguments: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Optional agent type when creating a session. Defaults to agentic." + "description": "Optional agent type when creating a session. Defaults to agentic. Available agent types are listed at runtime." } }, "required": ["action"], @@ -311,6 +310,23 @@ Arguments: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Optional agent type when creating a session. Defaults to agentic."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index b6aa1d8ed4..7dd6f9993c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -242,22 +242,11 @@ impl SessionMessageTool { } #[derive(Debug, Clone, Deserialize)] -enum SessionMessageAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} +struct SessionMessageAgentType(String); impl SessionMessageAgentType { - fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + fn as_str(&self) -> &str { + &self.0 } } @@ -323,8 +312,7 @@ Allowed agent types when creating a session: }, "agent_type": { "type": "string", - "enum": ["agentic", "Plan", "Cowork"], - "description": "Required when session_id is omitted. Not allowed when sending to an existing session." + "description": "Required when session_id is omitted. Not allowed when sending to an existing session. Available agent types are listed at runtime." } }, "required": ["message"], @@ -332,6 +320,23 @@ Allowed agent types when creating a session: }) } + async fn input_schema_for_model(&self) -> Value { + let mut schema = self.input_schema(); + if let Some(props) = schema.get_mut("properties") { + if let Some(obj) = props.get_mut("agent_type").and_then(|v| v.as_object_mut()) { + let registry = crate::agentic::agents::get_agent_registry(); + let ids = registry.get_agent_ids_for_session_creation().await; + let id_strs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect(); + obj.insert("enum".to_string(), json!(id_strs)); + obj.insert( + "description".to_string(), + json!("Required when session_id is omitted. Not allowed when sending to an existing session."), + ); + } + } + schema + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index bf0e4e44b7..532d518b96 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -24,23 +24,12 @@ impl SessionControlAction { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub enum SessionControlAgentType { - #[serde(rename = "agentic", alias = "Agentic", alias = "AGENTIC")] - Agentic, - #[serde(rename = "Plan", alias = "plan", alias = "PLAN")] - Plan, - #[serde(rename = "Cowork", alias = "cowork", alias = "COWORK")] - Cowork, -} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionControlAgentType(pub String); impl SessionControlAgentType { - pub const fn as_str(&self) -> &'static str { - match self { - Self::Agentic => "agentic", - Self::Plan => "Plan", - Self::Cowork => "Cowork", - } + pub fn as_str(&self) -> &str { + &self.0 } } diff --git a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs index 57ebaf23b5..bdb698f99e 100644 --- a/src/crates/execution/agent-runtime/tests/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/session_control_contracts.rs @@ -51,7 +51,7 @@ fn rejects_current_session_mutation_when_context_matches() { fn validates_create_requires_workspace_and_creator_session() { let mut input = base_input(SessionControlAction::Create); input.workspace = Some(std::env::temp_dir().to_string_lossy().to_string()); - input.agent_type = Some(SessionControlAgentType::Plan); + input.agent_type = Some(SessionControlAgentType("Plan".to_string())); let missing_creator = validate_session_control_input(&input, SessionControlValidationContext::default()); From 13def227d591317ab8bc09c83b2b2dc5f52e026b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 17:18:11 +0800 Subject: [PATCH 21/48] feat: legion preset storage + ACP agents in core zone --- src/apps/desktop/src/api/agentic_api.rs | 27 ++++ src/apps/desktop/src/lib.rs | 5 + .../assembly/core/src/agentic/agents/mod.rs | 1 + .../core/src/agentic/agents/team_presets.rs | 117 ++++++++++++++++++ .../src/app/scenes/agents/AgentsScene.tsx | 60 ++++++--- .../src/app/scenes/agents/agentVisibility.ts | 20 ++- .../app/scenes/agents/hooks/useAgentsList.ts | 35 +++++- .../src/locales/en-US/scenes/agents.json | 3 + .../src/locales/zh-CN/scenes/agents.json | 3 + .../src/locales/zh-TW/scenes/agents.json | 3 + 10 files changed, 252 insertions(+), 22 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/team_presets.rs diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 77921c0d26..4b51fd1857 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2491,6 +2491,33 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } +// ── Legion preset commands ────────────────────────────────────────── + +#[tauri::command] +pub async fn list_legion_presets() -> Result, String> { + bitfun_core::agentic::agents::team_presets::list_presets() +} + +#[tauri::command] +pub async fn get_legion_preset(id: String) -> Result { + bitfun_core::agentic::agents::team_presets::get_preset(&id) +} + +#[tauri::command] +pub async fn create_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::create_preset(&preset) +} + +#[tauri::command] +pub async fn update_legion_preset(preset: bitfun_core::agentic::agents::team_presets::LegionPreset) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::update_preset(&preset) +} + +#[tauri::command] +pub async fn delete_legion_preset(id: String) -> Result<(), String> { + bitfun_core::agentic::agents::team_presets::delete_preset(&id) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index e144064f9e..db5284ed4c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -924,6 +924,11 @@ pub async fn run() { api::agentic_api::generate_session_title, api::agentic_api::get_available_modes, api::agentic_api::get_default_review_team_definition, + api::agentic_api::list_legion_presets, + api::agentic_api::get_legion_preset, + api::agentic_api::create_legion_preset, + api::agentic_api::update_legion_preset, + api::agentic_api::delete_legion_preset, api::btw_api::btw_ask_stream, api::btw_api::btw_cancel, api::editor_ai_api::editor_ai_stream, diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index b55e436c32..fe62ad94ba 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -5,6 +5,7 @@ mod definitions; mod prompt_builder; mod registry; +pub mod team_presets; use crate::agentic::session::{SystemPromptCacheIdentity, UserContextCacheIdentity}; use crate::agentic::tools::framework::ToolExposure; diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs new file mode 100644 index 0000000000..067a911faa --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -0,0 +1,117 @@ +//! Legion preset storage. +//! +//! Each preset is a JSON file under `/legions/.json` describing +//! a team topology (nodes + edges) that the Team mode agent can materialise at +//! runtime via SessionControl / SessionMessage. + +use crate::infrastructure::get_path_manager_arc; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +const LEGIONS_SUBDIR: &str = "legions"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionPreset { + pub id: String, + pub name: String, + pub description: String, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionNode { + pub id: String, + pub agent: String, + pub role: String, + pub prompt: String, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub gate: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LegionEdge { + pub from: String, + pub to: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub condition: Option, +} + +fn legions_dir() -> PathBuf { + get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) +} + +fn preset_path(id: &str) -> PathBuf { + legions_dir().join(format!("{id}.json")) +} + +fn ensure_legions_dir() -> std::io::Result<()> { + let dir = legions_dir(); + std::fs::create_dir_all(&dir) +} + +/// List all saved legion presets (sorted by id). +pub fn list_presets() -> Result, String> { + let dir = legions_dir(); + if !dir.is_dir() { + return Ok(Vec::new()); + } + let mut out = Vec::new(); + let entries = std::fs::read_dir(&dir).map_err(|e| format!("Failed to read legions dir: {e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read dir entry: {e}"))?; + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "json") { + let raw = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + let preset: LegionPreset = serde_json::from_str(&raw) + .map_err(|e| format!("Failed to parse {}: {e}", path.display()))?; + out.push(preset); + } + } + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) +} + +/// Load a single preset by id. +pub fn get_preset(id: &str) -> Result { + let path = preset_path(id); + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + let raw = + std::fs::read_to_string(&path).map_err(|e| format!("Failed to read preset: {e}"))?; + serde_json::from_str(&raw).map_err(|e| format!("Failed to parse preset: {e}")) +} + +/// Create or overwrite a preset. +pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { + ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; + let path = preset_path(&preset.id); + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Update an existing preset (id must already exist). +pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { + let path = preset_path(&preset.id); + if !path.is_file() { + return Err(format!("Legion preset '{}' not found", preset.id)); + } + let raw = + serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; + std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) +} + +/// Delete a preset by id. +pub fn delete_preset(id: &str) -> Result<(), String> { + let path = preset_path(id); + if !path.is_file() { + return Err(format!("Legion preset '{id}' not found")); + } + std::fs::remove_file(&path).map_err(|e| format!("Failed to delete preset: {e}")) +} diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 49192fdc09..7efb3ef903 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -38,7 +38,7 @@ import { getAgentBadge, getAgentDescription, getCapabilityLabel } from './utils' import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; -import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; +import { isAgentInOverviewZone, buildCoreAgentIds, ACP_CORE_AGENT_PREFIX } from './agentVisibility'; import { CustomAgentAPI } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeSkillInfo, SubagentModelSelection } from '@/infrastructure/config/types'; @@ -279,26 +279,50 @@ const AgentsHomeView: React.FC = () => { }; }, []); - const coreAgentMeta = useMemo((): Record => ({ - agentic: { - role: t('coreAgentsZone.modes.agentic.role'), - ...CORE_AGENT_ACCENTS.agentic, - }, - Cowork: { - role: t('coreAgentsZone.modes.cowork.role'), - ...CORE_AGENT_ACCENTS.Cowork, - }, - ComputerUse: { - role: t('coreAgentsZone.modes.computerUse.role'), - ...CORE_AGENT_ACCENTS.ComputerUse, - }, - }), [t]); + const coreAgentMeta = useMemo((): Record => { + const meta: Record = { + agentic: { + role: t('coreAgentsZone.modes.agentic.role'), + ...CORE_AGENT_ACCENTS.agentic, + }, + Cowork: { + role: t('coreAgentsZone.modes.cowork.role'), + ...CORE_AGENT_ACCENTS.Cowork, + }, + ComputerUse: { + role: t('coreAgentsZone.modes.computerUse.role'), + ...CORE_AGENT_ACCENTS.ComputerUse, + }, + }; + // Append ACP external agent meta (use a neutral accent). + for (const agent of allAgents) { + if (agent.id.startsWith(ACP_CORE_AGENT_PREFIX) && !meta[agent.id]) { + meta[agent.id] = { + role: t('coreAgentsZone.modes.acpExternal.role', agent.name), + ...DEFAULT_CORE_AGENT_ACCENT, + }; + } + } + return meta; + }, [t, allAgents]); + + const acpEnabledIds = useMemo( + () => allAgents + .filter((a) => a.id.startsWith(ACP_CORE_AGENT_PREFIX)) + .map((a) => a.id.slice(ACP_CORE_AGENT_PREFIX.length)), + [allAgents], + ); - const coreAgents = useMemo(() => allAgents.filter((agent) => CORE_AGENT_IDS.has(agent.id)), [allAgents]); + const effectiveCoreAgentIds = useMemo(() => buildCoreAgentIds(acpEnabledIds), [acpEnabledIds]); + + const coreAgents = useMemo( + () => allAgents.filter((agent) => effectiveCoreAgentIds.has(agent.id)), + [allAgents, effectiveCoreAgentIds], + ); const visibleAgents = useMemo( - () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds)), - [filteredAgents, hiddenAgentIds], + () => filteredAgents.filter((agent) => isAgentInOverviewZone(agent, hiddenAgentIds, effectiveCoreAgentIds)), + [filteredAgents, hiddenAgentIds, effectiveCoreAgentIds], ); const scrollToZone = useCallback((targetId: string) => { diff --git a/src/web-ui/src/app/scenes/agents/agentVisibility.ts b/src/web-ui/src/app/scenes/agents/agentVisibility.ts index 34031091ab..b814441e0d 100644 --- a/src/web-ui/src/app/scenes/agents/agentVisibility.ts +++ b/src/web-ui/src/app/scenes/agents/agentVisibility.ts @@ -18,13 +18,29 @@ export const HIDDEN_AGENT_IDS = new Set([ ...FALLBACK_REVIEW_HIDDEN_AGENT_IDS, ]); +/** Prefix used by ACP external agents (e.g. acp__codex, acp__Claude_Code). */ +export const ACP_CORE_AGENT_PREFIX = 'acp__'; + /** Core mode agents shown in the top zone only; excluded from overview zone list and counts. */ -export const CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); +const STATIC_CORE_AGENT_IDS = new Set(['agentic', 'Cowork', 'ComputerUse']); + +/** Build the effective core-agent id set including enabled ACP external agents. */ +export function buildCoreAgentIds(acpEnabledIds: string[]): Set { + const ids = new Set(STATIC_CORE_AGENT_IDS); + for (const acpId of acpEnabledIds) { + ids.add(`${ACP_CORE_AGENT_PREFIX}${acpId}`); + } + return ids; +} + +/** Legacy static set kept for backward-compat callers that don't inject ACP ids. */ +export const CORE_AGENT_IDS = STATIC_CORE_AGENT_IDS; /** Agents that appear in the bottom overview grid (same pool as filter chip counts). */ export function isAgentInOverviewZone( agent: { id: string }, hiddenAgentIds: ReadonlySet = HIDDEN_AGENT_IDS, + coreAgentIds: ReadonlySet = CORE_AGENT_IDS, ): boolean { - return !hiddenAgentIds.has(agent.id) && !CORE_AGENT_IDS.has(agent.id); + return !hiddenAgentIds.has(agent.id) && !coreAgentIds.has(agent.id); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index ff25b89036..463ef75c6a 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -16,10 +16,11 @@ import { useNotification } from '@/shared/notification-system'; import type { DynamicToolInfo } from '@/shared/types/agent-api'; import type { AgentWithCapabilities } from '../agentsStore'; import { enrichCapabilities } from '../utils'; -import { HIDDEN_AGENT_IDS, isAgentInOverviewZone } from '../agentVisibility'; +import { HIDDEN_AGENT_IDS, isAgentInOverviewZone, ACP_CORE_AGENT_PREFIX } from '../agentVisibility'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { loadDefaultReviewTeamDefinition } from '@/shared/services/reviewTeamService'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { ACPClientAPI, type AcpClientInfo } from '@/infrastructure/api/service-api/ACPClientAPI'; export type FilterLevel = 'all' | 'builtin' | 'user' | 'project'; export type FilterType = 'all' | 'mode' | 'subagent'; @@ -203,6 +204,14 @@ export function useAgentsList({ ]).catch((): Record => ({})), ]); + // Fetch enabled ACP external agents for the core zone. + let acpClients: AcpClientInfo[] = []; + try { + acpClients = (await ACPClientAPI.getClients()).filter((c) => c.enabled); + } catch { + // ACP not initialized — no external agents to show. + } + const profileMap = buildProfileMap(modes); const profileEntries = Object.values(profileMap); @@ -275,7 +284,29 @@ export function useAgentsList({ }); }); - setAllAgents([...modeAgents, ...subAgents]); + const acpAgents: AgentWithCapabilities[] = acpClients.map((client): AgentWithCapabilities => { + const agentId = `${ACP_CORE_AGENT_PREFIX}${client.id}`; + const displayName = client.name || client.id; + return enrichCapabilities({ + key: `acp::${client.id}`, + id: agentId, + name: displayName, + description: client.command + ? `External ACP agent (${client.command}${client.args.length ? ' ' + client.args.join(' ') : ''})` + : `External ACP agent — ${client.status}`, + isReadonly: client.readonly, + isReview: false, + toolCount: 0, + defaultTools: [], + defaultEnabled: client.enabled, + effectiveEnabled: client.enabled, + source: 'builtin' as AgentSource, + capabilities: [], + agentKind: 'subagent' as const, + }); + }); + + setAllAgents([...modeAgents, ...subAgents, ...acpAgents]); setAvailableTools(tools); setConfiguredModels(models); setModeProfiles(profileMap); diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 736e557257..000a6ad6bb 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "Desktop automation agent" + }, + "acpExternal": { + "role": "External ACP agent — {{name}}" } } }, diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index b1fe51e324..42b5607124 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "电脑操作智能体" + }, + "acpExternal": { + "role": "外部 ACP 智能体 — {{name}}" } } }, diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index f78830996b..b8c1a1c230 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -41,6 +41,9 @@ }, "computerUse": { "role": "電腦操作智能體" + }, + "acpExternal": { + "role": "外部 ACP 智能體 — {{name}}" } } }, From a90dff18724339c37bda93f4f294d34369331f8e Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 17:23:11 +0800 Subject: [PATCH 22/48] feat: legion commander prompt + HookResult::Abort guard framework --- .../src/agentic/agents/prompts/team_mode.md | 332 ++++-------------- .../core/src/agentic/tools/post_call_hooks.rs | 15 +- .../src/agentic/tools/tool_context_runtime.rs | 22 +- .../agent-runtime/src/post_call_hooks.rs | 41 ++- 4 files changed, 133 insertions(+), 277 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index 30b1ce5b4e..ac7611052f 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md @@ -1,316 +1,108 @@ -You are BitFun in **Team Mode** — a virtual engineering team orchestrator. You coordinate specialized roles through a full sprint workflow to deliver high-quality software. - -You have access to a set of **gstack skills** via the Skill tool and BitFun's existing **Task** tool for launching sub-agents inside the same session. Each skill embodies a specialist role with deep expertise and a battle-tested methodology. Your job is to know WHEN to load each role's methodology, WHEN to dispatch independent work to existing sub-agents, and HOW to weave their outputs into a coherent delivery pipeline. - -IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. +You are BitFun in **Team Mode** — a legion commander. You orchestrate specialized agent sessions through a fractal deployment topology to deliver complex work. {LANGUAGE_PREFERENCE} -# MANDATORY: Built-in Runtime Boundary - -Team Mode is a BitFun built-in mode. It MUST be self-contained inside BitFun's runtime: - -- Do not require Claude Code, external gstack installs, external helper binaries, or files under `~/.claude`, `~/.gstack`, or repo-local skill-definition directories. -- Use only BitFun tools exposed in the current session, the bundled Skill contents, the Task tool's enabled sub-agents, and ordinary project tools such as `git`, `rg`, package-manager scripts, and test commands. -- Store any Team-owned durable artifacts under BitFun state paths such as `.bitfun/team/` or `$HOME/.bitfun/team/` when a skill asks for local team state. -- If a bundled skill mentions legacy helper behavior, reinterpret it through BitFun built-ins. Never ask the user to build, install, or enable an external helper just to make Team Mode work. - -# MANDATORY: Team-Orchestration Rule - -**Team Mode is not a single assistant pretending to be many people.** For non-trivial work, you MUST make the team visible by combining: - -1. **Skill**: load the role methodology and output contract. -2. **Task**: dispatch independent investigation / review / QA / research work to the existing enabled sub-agents in this workspace. -3. **Synthesis**: reconcile the role outputs in the main orchestrator before deciding or editing. - -Do not add or assume special built-in role sub-agent types. Use the sub-agents that the Task tool says are available in the current workspace. Prefer role-specific custom sub-agents when available; otherwise use general-purpose read-only sub-agents for investigation/review and keep implementation in the main Team session. +# Commander's Iron Rule -You MUST load the appropriate gstack skill before writing code, creating a final plan, or making file changes. This is not optional. Team Mode exists to run the specialist workflow with actual delegation where it helps. +**You only orchestrate. You never execute.** -There are only three exceptions to this rule: -1. The user explicitly says "skip [phase/skill], just do [X]" — respect it once, note the skip in your todo list -2. A pure config-only change (single file, zero logic) — Build → Review only -3. An emergency hotfix explicitly labeled as such — Investigate → Build → Review → Ship +All implementation, file operations, commands, and code changes MUST be delegated to legion members. Your role is task decomposition, agent creation, message dispatch, and quality gate enforcement. If you find yourself reaching for Read/Write/Edit/ExecCommand, you are doing it wrong. -In all other cases, invoke the skill first, then dispatch Task sub-agents for independent work whenever the phase contains separable investigation, review, testing, or audit tracks. +# Your Weapons -# Task Dispatch Rules +| Tool | Purpose | +|---|---| +| `SessionControl(action:"create")` | Create a new agent session (legion member node). `agent_type` accepts any registered agent ID, including Plan/agentic/Debug/Multitask/Team/DeepResearch/acp__* and custom agents. | +| `SessionControl(action:"list")` | List all sessions in the workspace. | +| `SessionControl(action:"cancel")` | Cancel a running session's turn. | +| `SessionControl(action:"delete")` | Remove a completed session. | +| `SessionMessage(session_id, message)` | Send a task to a legion member. The member executes asynchronously and automatically returns results via reply route. | +| `SessionHistory(session_id)` | Export a legion member's transcript for review. Use before gate decisions. | +| `Task(subagent_type, prompt, run_in_background)` | Dispatch a sub-agent for focused, scoped work inside a single session. | +| `get_goal` / `create_goal` / `update_goal` | Track campaign progress. Status flows: pending → in-progress → complete. Use `update_goal` to mark blocking when stuck. | -Use Task to create real team behavior without changing BitFun's global agent roster. +# The Three-Bee Atomic Unit -- Always read the Task tool's available agent list before choosing `subagent_type`; only use listed enabled sub-agents. -- Prefer custom user/project sub-agents whose name or description matches the role (`designer`, `security`, `qa`, `review`, `research`, etc.). -- If no suitable sub-agent exists, say so briefly and run that role in the main orchestrator after loading its Skill. -- Launch multiple independent Task calls in a single assistant message so BitFun runs them concurrently. -- Keep Task prompts small and owned: give each sub-agent its role, exact question, file/path scope, expected output format, and whether it is read-only. -- Never ask a Task sub-agent to mutate files unless the selected sub-agent is explicitly meant for that and the phase allows mutations. +Every legion member is a full agent session capable of independently reading, writing, executing commands, and communicating with other sessions via SessionMessage. Three specialized roles form the minimal execution unit: -# Your Team Roster +- **Prompt Bee**: Loads skills, retrieves methodology, prepares context before execution begins. +- **Execute Bee**: Performs the actual work — writes code, runs commands, produces output. +- **Review Bee**: Reads SessionHistory transcripts, audits behavior, and gates output quality. Does NOT execute. -These are the specialist roles available to you as skills. Invoke them via the **Skill** tool to load methodology, then dispatch existing Task sub-agents for separable work: +These three bees communicate directly via SessionMessage. They form an internal loop — review bee inspects output, sends corrections back to execute bee or prompt bee, and the cycle repeats until the gate passes. -| Role | Skill Name | When to Use | -|------|-----------|-------------| -| **YC Office Hours** | `office-hours` | User describes an idea or asks "is this worth building" — deep product thinking | -| **CEO Reviewer** | `plan-ceo-review` | Challenge scope, find the 10-star product hiding in the request | -| **Eng Manager** | `plan-eng-review` | Lock architecture, data flow, edge cases, test matrix | -| **Senior Designer** | `plan-design-review` | UI/UX audit, rate each design dimension, detect AI slop | -| **Independent Reviewer** | `CodeReview` via Task | Read-only adversarial review selected to match change risk | -| **QA Lead** | `qa` | Browser-based QA testing, find and fix bugs, regression tests | -| **QA Reporter** | `qa-only` | Same QA methodology but report-only, no code changes | -| **Release Engineer** | `ship` | Tests → PR → deploy. The last mile. | -| **Chief Security Officer** | `cso` | OWASP Top 10 + STRIDE threat model audit | -| **Debugger** | `investigate` | Systematic root-cause debugging with Iron Law: no fixes without root cause | -| **Auto-Review Pipeline (legacy, sequential)** | `autoplan` | Only when the user explicitly asks for the legacy single-thread pipeline. Default Phase 2 path is the parallel fan-out, not this. | -| **Designer Who Codes** | `design-review` | Design audit then fix what it finds with atomic commits | -| **Design Partner** | `design-consultation` | Build a complete design system from scratch | -| **Technical Writer** | `document-release` | Update all docs to match what was shipped | -| **Eng Manager (Retro)** | `retro` | Weekly engineering retrospective with per-person breakdowns | +# Deployment Protocol -# Skill Invocation Rules +## 1. Task Decomposition -The following table is **mandatory**. Match the user's request to the correct row and invoke the listed skill before doing anything else. +Analyze the user's request. Break it into independent subtasks. Each subtask that is atomic (cannot be meaningfully split further) is assigned to one agent session. -| If the user... | You MUST first invoke... | Only then can you... | -|----------------|--------------------------|----------------------| -| Describes a new idea, feature, or requirement | `office-hours` | Create any plan or design doc | -| Has a design doc or plan ready for review | the **parallel review fan-out** of Phase 2 (CEO + Eng + Design/CSO as applicable, in one message) | Write any code | -| Explicitly asks for the legacy sequential pipeline | `autoplan` | Write any code | -| Wants only one review type (CEO / Design / Eng) | the specific skill | Proceed to the next phase | -| Just finished writing code | `CodeReview` via Task | Proceed to QA or ship | -| Reports a bug or unexpected behavior | `investigate` | Touch any code | -| Says "ship it", "deploy", "create a PR" | `ship` | Run any deploy commands | -| Asks "does this work?" or "test this" | `qa` | Mark anything as done | -| Asks about security, auth, or data safety | `cso` | Modify any auth/data-related code | -| Wants design system or UI polish | `design-review` or `design-consultation` | Implement UI changes | -| Wants docs updated after shipping | `document-release` | Close out the task | -| Wants a retrospective | `retro` | Move to the next sprint | +Determine the dependency graph: which subtasks can run in parallel (no shared output dependency), and which must be serial (output of A feeds into B). -# The Sprint Workflow +## 2. Create Legion +For each subtask, create an agent session: ``` -Think → Plan → Build → Review → Test → Ship → Reflect +SessionControl(action:"create", session_name:"-", agent_type:"") ``` +Choose `agent_type` based on the role needed: Plan for analysis/design, agentic for implementation, DeepReview for quality gate, acp__* for external agents. -**MANDATORY: Every new feature or non-trivial change starts at Phase 1 (Think). Do not enter a later phase without completing all prior mandatory phases.** - -**Phases are sequential, but work *inside* a phase is parallel whenever possible.** In particular, all reviewer / audit / investigation tracks inside Phase 2 (Plan), Phase 4 (Review), and report-only QA/security checks MUST be fanned out with Task whenever there is a suitable existing sub-agent — see "Parallel Fan-out Protocol". - -## Phase 1: Think (REQUIRED for new ideas and features) - -**Entry condition:** User describes a new idea, feature, or requirement. - -**You MUST:** -1. Announce the role transition (see Role Transition Protocol below) -2. Invoke `office-hours` skill -3. Use Task only for independent discovery that sharpens the design doc (market/context research, codebase exploration, existing workflow mapping). Keep the final problem framing in the main orchestrator. -4. Produce the design doc -5. Confirm with the user before proceeding to Phase 2 - -**You must NOT write any code or create any implementation plan until Phase 1 is complete.** - -## Phase 2: Plan (REQUIRED before writing code) - -**Entry condition:** A design doc exists (from Phase 1 or provided by user). - -**You MUST:** -1. Announce the role transition once for the whole review batch (e.g. `[ROLE: Plan Review Council] Fanning out CEO + Design + Eng (+ CSO) in parallel...`). -2. Load the applicable reviewer skills, then **fan out reviewer work in parallel** by emitting **multiple `Task` tool calls in a single assistant message** (see "Parallel Fan-out Protocol" below). The applicable reviewers are: - - `plan-ceo-review` — strategic scope challenge (always) - - `plan-eng-review` — architecture and test plan (always) - - `plan-design-review` — UI/UX review (only if UI is involved) - - `cso` — security review (only if auth / data / network surface is touched) - - Do **not** invoke `autoplan` here — `autoplan` is sequential and is reserved for the case where the user explicitly asks for the legacy single-thread pipeline. -3. If a role has no suitable Task sub-agent, run that role in the main orchestrator using the loaded skill and mark it as `main-session`. -4. After all reviewers return, write a **Review Synthesis** block (see "Review Synthesis Template" below) that merges blocking issues, conflicts, and the final decision. -5. Get user approval on the synthesized plan before proceeding. - -**You must NOT write any code until Phase 2 is complete and the plan is approved.** - -## Phase 3: Build (ONLY after plan approval) - -**Entry condition:** Plan is approved from Phase 2. - -- Write code using standard tools (Read, Write, Edit, ExecCommand, etc.) -- Use TodoWrite to track implementation progress -- Follow the architecture decisions from the plan exactly - -## Phase 4: Review (REQUIRED before testing or shipping) - -**Entry condition:** Implementation is complete. - -**You MUST:** -1. Announce that an independent review is starting without exposing internal agent or Task names. -2. Dispatch one read-only `CodeReview` Task and include the relevant correctness, security, architecture, and UI lenses in its prompt. Do not choose a parallel reviewer count here; broader coverage belongs to the unified `/review` path and its cost confirmation. -3. Keep the reviewer read-only. The main Team session owns a separate remediation phase after findings are synthesized. -4. Write a **Review Synthesis** block organized by severity, evidence, and residual coverage rather than internal source roles. -5. Fix all AUTO-FIX issues immediately. Present ASK items to the user and wait for decisions. - -**You must NOT proceed to Test or Ship until all AUTO-FIX items are resolved.** - -## Phase 5: Test (REQUIRED before shipping) - -**Entry condition:** Review phase passed (no unresolved AUTO-FIX items). - -**You MUST:** -1. Announce the role transition -2. Invoke `qa` for browser-based testing (if UI is involved), or `qa-only` for report-only -3. Use Task with `ComputerUse` or another suitable QA/browser sub-agent when available; keep fix decisions in the main Team session unless the invoked QA workflow explicitly owns fixes. -4. Each bug found generates a regression test before the fix -5. Re-run independent `CodeReview` if significant code changes were made during QA - -## Phase 6: Ship (REQUIRED to close out the work) - -**Entry condition:** Tests pass. +## 3. Topological Sort and Fan-Out -**You MUST:** -1. Announce the role transition -2. Invoke `ship` to run final tests, create PR, and handle the release - -## Phase 7: Reflect (after shipping) - -- Invoke `retro` for a sprint retrospective -- Invoke `document-release` to update project docs to match what was shipped - -# Phase Gates - -These are hard stops. You cannot proceed past a gate without satisfying its condition. - -**Gate 1 — Before Build:** -A completed design doc OR an approved autoplan review output MUST exist. -If neither exists, announce: "Phase Gate 1: No design doc or plan found. Invoking office-hours now." Then invoke `office-hours`. - -**Gate 2 — Before Ship:** -Independent Review MUST have run and all accepted remediation items MUST be resolved. -If review has not run, announce: "Phase Gate 2: Review has not run. Starting independent review now." Then dispatch the appropriate `CodeReview` Task path. - -# Parallel Fan-out Protocol - -Team Mode is a **virtual team**, not a single specialist running serially. Parallelize independent planning, consultation, and discovery roles when suitable sub-agents are available. Product code Review is the exception: it uses one `CodeReview` Task here, while broader reviewer fan-out stays behind the unified `/review` policy and consent flow. - -**How to fan out:** - -- Emit **multiple `Task` tool calls inside one single assistant message** after loading the needed skill methodology. The platform's tool pipeline detects concurrency-safe calls and runs them with `join_all`. If you split them across separate assistant turns, you lose the parallelism and waste the user's time and tokens. -- Announce the batch **once** with a single role transition header (e.g. `[ROLE: Plan Review Council] Fanning out 3 reviewers in parallel...`). Do **not** print one transition header per skill in this case — that defeats the purpose of a batch. -- Pick only the reviewers that genuinely apply to the change. Do not invoke `plan-design-review` on a backend-only change just to fill the slate. -- Give every Task a role label in `description`, for example `CEO scope review`, `Eng architecture review`, `Security diff audit`, `QA browser smoke`. -- In every Task prompt, include: role, objective, scope/files, constraints, output format, and "return findings only; do not modify files" unless the phase explicitly allows that sub-agent to fix. - -**When NOT to fan out:** - -- Phases that produce artifacts the next step depends on (Build, Ship, Investigate root-cause loops). These remain sequential. -- The legacy `autoplan` skill — it is **sequential by design**. Only invoke `autoplan` if the user explicitly asks for it ("run autoplan", "do the full sequential pipeline"). The default path for Phase 2 is the parallel fan-out described above. -- A single reviewer scenario (e.g. user explicitly asked for "just the CEO review") — load that skill and decide whether one Task would materially improve evidence. Do not create parallelism for its own sake. - -**Concurrency safety:** - -- `Skill`, `Read`, `Grep`, `Glob`, `WebSearch`, `WebFetch`, and read-only `Task` calls are concurrency-safe and will run in parallel inside one batch. -- `Write`, `Edit`, `Delete`, `ExecCommand`, `Git` mutations break the batch and run serially. Do **not** mix them into a fan-out batch. - -# Review Synthesis Template - -After every parallel review batch (Phase 2 or Phase 4), you MUST emit a Review Synthesis block before continuing. Use this exact structure: +Sort subtasks by their dependency graph. All subtasks on the same level (no dependencies between them) are dispatched in parallel. +For each subtask in the current level: ``` ---- -## Review Synthesis (sources: , , ...) - -### Blocking issues (must resolve before next phase) -- [] — proposed fix: - -### Non-blocking suggestions -- [] - -### Conflicts between roles -- says X, says Y. Resolution: . - -### Agreements / consensus -- - -### Decision -- Proceed to / Block on user input / Re-run with . ---- +SessionMessage(session_id:"", message:"") ``` +Make every dispatch in a single assistant message so they run concurrently. -If a reviewer returned nothing actionable, still list them in the `sources:` line so the user can see who was consulted. This block is the single source of truth the orchestrator uses to gate the next phase. +## 4. Wait and Collect -# Role Transition Protocol +Each SessionMessage returns automatically when the agent completes its turn. Wait for all parallel dispatches to finish before proceeding to the next level. -When invoking any skill, you MUST announce the transition with this exact format before invoking the Skill tool: +## 5. Review and Gate -``` ---- -[ROLE: {Role Name}] Invoking {skill-name}... ---- -``` +After receiving output, use SessionHistory to inspect the agent's transcript. Verify: +- Did the agent read relevant files before editing? +- Did the agent verify its output (tests pass, commands succeed)? +- Are all acceptance criteria met? -Examples: -``` ---- -[ROLE: YC Office Hours] Invoking office-hours... ---- +If the output fails review, send corrections back: ``` +SessionMessage(session_id:"", message:"[CORRECTION] ") ``` ---- -[ROLE: Eng Manager] Invoking plan-eng-review... ---- -``` +Repeat until the gate passes. -After the skill completes, announce the return with this format: +## 6. Escalate -``` ---- -[ROLE: BitFun Orchestrator] {skill-name} complete. Moving to {next phase/action}. ---- -``` +When a subtask cannot be completed at the current level — the agent hit a complexity wall, discovered new dependencies, or the task itself decomposes further — create a new sub-legion. Decompose the stuck subtask into its own subtasks, create new agent sessions, and repeat the protocol recursively. -This makes the team structure visible. Never silently invoke a skill. +## 7. Complete Campaign -# When to Abbreviate the Workflow +When all subtasks pass their gates, mark the campaign complete: +``` +update_goal(status:"complete") +``` -The workflow can only be abbreviated in these specific cases. Skipping a phase does not mean skipping the mandatory skill — it means the phase genuinely does not apply. +# Fractal Nesting -| Scenario | Allowed shortcut | -|----------|-----------------| -| Pure config change (1 file, zero logic) | Build → Review only | -| Emergency hotfix (explicitly labeled) | Investigate → Build → Review → Ship | -| Bug report with clear root cause already known | Investigate → Build → Review → Ship | -| User explicitly invokes a specific skill by name | Go directly to that skill, then continue from that phase | -| Security audit only | Just invoke `cso` | +Any agent session you create is also capable of creating its own sub-sessions. A legion member stuck on a complex problem can itself become a commander. This is not a bug — it is the design. Each level only cares about the level directly below it. The topology is self-similar at every scale. -**In all other cases, start from the correct entry point in the Sprint Workflow.** +# Gate Rules -When a user says "run a review", "do QA", or "ship it" — those are explicit skill invocations. Honor them immediately. This is not a shortcut — it means the user is entering the workflow at a specific phase. +- **Never accept output that skips verification.** If an agent claims completion but ran no test/check commands, reject it. +- **Never accept output that skips reading.** If an agent edits a file without first reading it, reject it. +- **Never retry the same approach more than 3 times.** If an agent fails the same tool call repeatedly, it is stuck. Decompose the task differently or escalate. +- **Always use SessionHistory before gate decisions.** Do not trust the agent's summary — read the transcript. # Professional Objectivity -Prioritize technical accuracy over validating beliefs. The CEO reviewer and Eng Manager skills will challenge the user's assumptions — that is by design. Great products come from honest feedback, not agreement. +Prioritize technical accuracy over validating beliefs. Delegate to the right agent type for each task. Do not pretend to be many people in a single session — create real agent sessions for real parallelism. # Tone and Style - NEVER use emojis unless the user explicitly requests it -- Be concise when orchestrating between phases -- When a skill is loaded, follow its instructions precisely — the skill IS the expert -- Report phase transitions clearly using the Role Transition Protocol -- Use TodoWrite to track sprint progress across phases — each phase is a top-level todo - -# Task Management - -Use TodoWrite frequently to track sprint progress. Structure it as: -- Phase 1: Think — [status] -- Phase 2: Plan — [status] -- Phase 3: Build — [status] -- Phase 4: Review — [status] -- Phase 5: Test — [status] -- Phase 6: Ship — [status] - -Mark phases complete only after their mandatory skill has run and its output has been acted on. - -# Doing Tasks - -- NEVER propose changes to code you haven't read. Read first, then modify. -- Use the AskUserQuestion tool when you need user decisions between phases. -- Be careful not to introduce security vulnerabilities. -- When invoking a skill, trust its methodology and follow its instructions fully. -- If a skill's output contradicts the current plan, surface the conflict to the user before proceeding. +- Be concise when orchestrating +- Use TodoWrite to track the dependency graph and progress of each legion member +- Report gate results clearly: PASS (with evidence) or FAIL (with specific fix instruction) diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index db411435d2..09923908db 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -7,7 +7,7 @@ use crate::agentic::deep_review::tool_measurement; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor, + run_successful_tool_post_call_hooks, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::Value; @@ -22,13 +22,22 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec ) { tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context); } + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &ToolUseContext, + ) -> HookResult { + HookResult::Continue + } } pub(crate) fn record_successful_tool_call( tool_name: &str, input: &Value, context: &ToolUseContext, -) { +) -> HookResult { let mut executor = CorePostCallHookExecutor; - run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor); + run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor) } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 7c993eb69c..9244c9b5df 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -208,7 +208,27 @@ pub(crate) async fn call_with_tool_runtime_hooks( }; if result.is_ok() { - post_call_hooks::record_successful_tool_call(tool_name, input, context); + let hook_result = post_call_hooks::record_successful_tool_call(tool_name, input, context); + if let bitfun_agent_runtime::post_call_hooks::HookResult::Abort { + reason, + fix_instruction, + .. + } = hook_result + { + let interception = serde_json::json!({ + "intercepted": true, + "reason": reason, + "fix_instruction": fix_instruction, + }); + return Ok(vec![ToolResult::Result { + data: interception, + result_for_assistant: Some(format!( + "[ToolGuard Intercepted] Result for tool {} was rejected.\nReason: {}\nFix: {}", + tool_name, reason, fix_instruction + )), + image_attachments: None, + }]); + } } result diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index a8ae2ac199..d1566f24fc 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -11,10 +11,14 @@ use std::path::Path; pub enum RuntimeHookKind { SuccessfulToolPostCall, DeepReviewSharedContextToolUse, + BehaviorGuard, } -pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 1] { - [RuntimeHookKind::DeepReviewSharedContextToolUse] +pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 2] { + [ + RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookKind::BehaviorGuard, + ] } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -26,6 +30,22 @@ pub enum RuntimeHookErrorPolicy { RecordWarning, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HookResult { + Continue, + Abort { + reason: String, + fix_instruction: String, + max_retries: u32, + }, +} + +impl HookResult { + pub fn is_abort(&self) -> bool { + matches!(self, HookResult::Abort { .. }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct RuntimeHookPlan { id: String, @@ -151,6 +171,13 @@ pub trait SuccessfulToolPostCallHookExecutor { input: &Value, context: &C, ); + + fn behavior_guard( + &mut self, + tool_name: &str, + input: &Value, + context: &C, + ) -> HookResult; } pub fn run_successful_tool_post_call_hooks( @@ -158,7 +185,8 @@ pub fn run_successful_tool_post_call_hooks( input: &Value, context: &C, executor: &mut E, -) where +) -> HookResult +where E: SuccessfulToolPostCallHookExecutor, { for hook in successful_tool_post_call_hooks() { @@ -166,9 +194,16 @@ pub fn run_successful_tool_post_call_hooks( RuntimeHookKind::DeepReviewSharedContextToolUse => { executor.record_deep_review_shared_context_tool_use(tool_name, input, context); } + RuntimeHookKind::BehaviorGuard => { + let result = executor.behavior_guard(tool_name, input, context); + if result.is_abort() { + return result; + } + } RuntimeHookKind::SuccessfulToolPostCall => {} } } + HookResult::Continue } #[derive(Debug, Clone, Copy)] From 56e6980b7ef20390ecb1a21f2b0b94f16e9dca78 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 18:03:45 +0800 Subject: [PATCH 23/48] feat: legion UI components + 18 orchestration patterns --- .../agents/components/CreateLegionPage.tsx | 150 +++++++ .../scenes/agents/components/LegionCard.scss | 99 +++++ .../scenes/agents/components/LegionCard.tsx | 86 ++++ .../agents/data/orchestration-patterns.ts | 392 ++++++++++++++++++ 4 files changed, 727 insertions(+) create mode 100644 src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx create mode 100644 src/web-ui/src/app/scenes/agents/components/LegionCard.scss create mode 100644 src/web-ui/src/app/scenes/agents/components/LegionCard.tsx create mode 100644 src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts diff --git a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx new file mode 100644 index 0000000000..a14ef9e928 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx @@ -0,0 +1,150 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { ArrowLeft, GitBranch, Network } from 'lucide-react'; +import { Button, IconButton } from '@/component-library'; +import PATTERNS, { + type LegionPatternNode, + type LegionPatternEdge, +} from '../data/orchestration-patterns'; +import '../AgentsView.scss'; + +interface CreateLegionPageProps { + onBack: () => void; +} + +const CreateLegionPage: React.FC = ({ onBack }) => { + const [selectedPatternId, setSelectedPatternId] = useState(PATTERNS[0]?.id ?? ''); + + const patternOptions = useMemo(() => PATTERNS, []); + + const selectedPattern = useMemo( + () => patternOptions.find((p) => p.id === selectedPatternId) ?? null, + [patternOptions, selectedPatternId], + ); + + const handleSelectPattern = useCallback((id: string) => { + setSelectedPatternId(id); + }, []); + + const handleSave = useCallback(() => { + // TODO: save legion preset to backend via Tauri command + onBack(); + }, [onBack]); + + const renderNodeList = (nodes: LegionPatternNode[]) => ( +
+ {nodes.map((node, i) => ( +
+ {i + 1} +
+ {node.role} + {node.agent} +
+ {node.gate ? GATE : null} +
+ ))} +
+ ); + + const renderEdgeList = (edges: LegionPatternEdge[], nodes: LegionPatternNode[]) => ( +
+ {edges.map((edge) => { + const fromNode = nodes.find((n) => n.id === edge.from); + const toNode = nodes.find((n) => n.id === edge.to); + return ( +
${edge.to}`} className="legion-edge-item"> + {fromNode?.role ?? edge.from} + + {toNode?.role ?? edge.to} + {edge.condition ? ( + [{edge.condition}] + ) : null} +
+ ); + })} +
+ ); + + return ( +
+
+ + + +

+ {selectedPattern ? selectedPattern.name : 'Choose a Pattern'} +

+
+ + {/* Pattern selector */} +
+

Orchestration Patterns

+
+ {patternOptions.map((pattern) => ( +
handleSelectPattern(pattern.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)} + data-testid="legion-pattern-option" + data-pattern-id={pattern.id} + > + + {pattern.name} +
+ ))} +
+
+ + {selectedPattern ? ( + <> + {/* Summary */} +
+

Overview

+

{selectedPattern.description}

+
+ Complexity: L{selectedPattern.complexityLevel} + {selectedPattern.nodes.length} nodes + {selectedPattern.edges.length} edges +
+
+ + {/* Nodes */} +
+

+ Nodes ({selectedPattern.nodes.length}) +

+ {renderNodeList(selectedPattern.nodes)} +
+ + {/* Edges */} +
+

+ Edges ({selectedPattern.edges.length}) +

+ {selectedPattern.edges.length > 0 + ? renderEdgeList(selectedPattern.edges, selectedPattern.nodes) + :

No edges (self-contained agent)

} +
+ + {/* Actions */} +
+ + +
+ + ) : null} +
+ ); +}; + +export default CreateLegionPage; diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss new file mode 100644 index 0000000000..957aaf6cfb --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss @@ -0,0 +1,99 @@ +.legion-card { + cursor: pointer; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + border-radius: 10px; + background: var(--color-surface-card); + border: 1px solid var(--color-border-subtle); + transition: border-color 0.15s, box-shadow 0.15s; + + &:hover { + border-color: var(--color-border-accent); + box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); + } + + &__header { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &__icon-area { + flex-shrink: 0; + } + + &__icon { + width: 36px; + height: 36px; + border-radius: 8px; + background: var(--color-surface-accent); + color: var(--color-text-accent); + display: flex; + align-items: center; + justify-content: center; + } + + &__header-info { + flex: 1; + min-width: 0; + } + + &__title-row { + display: flex; + align-items: center; + gap: 6px; + } + + &__name { + font-size: 14px; + font-weight: 600; + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__badges { + display: flex; + gap: 4px; + flex-shrink: 0; + } + + &__body { + flex: 1; + } + + &__desc { + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + &__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 6px; + } + + &__meta { + display: flex; + gap: 10px; + } + + &__meta-item { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + color: var(--color-text-tertiary); + } +} diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx new file mode 100644 index 0000000000..498f8effb5 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { GitBranch, Users, Network } from 'lucide-react'; +import { Badge } from '@/component-library'; +import type { LegionPattern } from '../data/orchestration-patterns'; +import './LegionCard.scss'; + +interface LegionCardProps { + pattern: LegionPattern; + index?: number; + onOpenDetails: (pattern: LegionPattern) => void; +} + +const COMPLEXITY_LABELS: Record = { + 1: 'L1', + 2: 'L2-L3', + 3: 'L3', + 4: 'L4', + 5: 'L5-L6', + 6: 'L6', + 7: 'L7', +}; + +const LegionCard: React.FC = ({ + pattern, + index = 0, + onOpenDetails, +}) => { + const gateNodes = pattern.nodes.filter((n) => n.gate).length; + const openDetails = () => onOpenDetails(pattern); + + return ( +
e.key === 'Enter' && openDetails()} + aria-label={pattern.name} + data-testid="legion-list-item" + data-legion-id={pattern.id} + > +
+
+
+ +
+
+
+
+ {pattern.name} +
+ + {COMPLEXITY_LABELS[pattern.complexityLevel] ?? `L${pattern.complexityLevel}`} + +
+
+
+
+ +
+

{pattern.description}

+
+ +
+
+ + + {pattern.nodes.length} nodes + + + + {pattern.edges.length} edges + + {gateNodes > 0 ? ( + + {gateNodes} gate + + ) : null} +
+
+
+ ); +}; + +export default LegionCard; diff --git a/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts new file mode 100644 index 0000000000..ca7e440bce --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/data/orchestration-patterns.ts @@ -0,0 +1,392 @@ +/** + * 18 built-in orchestration patterns for legion templates. + * Each pattern maps to the orchestration-patterns skill library. + */ +export interface LegionPatternNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPatternEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPattern { + id: string; + name: string; + description: string; + complexityLevel: number; + nodes: LegionPatternNode[]; + edges: LegionPatternEdge[]; +} + +const PATTERNS: LegionPattern[] = [ + { + id: 'sparc-dev', + name: 'SPARC Development', + description: '5-stage SPARC development pipeline: specification → pseudocode → architecture → refinement → completion', + complexityLevel: 4, + nodes: [ + { id: 'researcher', agent: 'Plan', role: 'Research Bee', prompt: 'Gather requirements, define acceptance criteria, identify constraints and edge cases.' }, + { id: 'decomposer', agent: 'Plan', role: 'Decompose Bee', prompt: 'Decompose into executable sub-tasks, annotate complexity, define dependencies.' }, + { id: 'architect', agent: 'agentic', role: 'Architect Bee', prompt: 'Design modules, define interfaces, resolve constraints.' }, + { id: 'implementer', agent: 'agentic', role: 'Implement Bee', prompt: 'Implement according to architecture and interface contracts.' }, + { id: 'tester', agent: 'agentic', role: 'Test Bee', prompt: 'Write and run automated tests. Coverage ≥ 80%, all ACs pass.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Code review and documentation generation.', gate: true }, + ], + edges: [ + { from: 'researcher', to: 'decomposer' }, + { from: 'decomposer', to: 'architect' }, + { from: 'architect', to: 'implementer' }, + { from: 'architect', to: 'tester' }, + { from: 'implementer', to: 'reviewer' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'implementer', condition: 'fail' }, + { from: 'reviewer', to: 'tester', condition: 'fail' }, + ], + }, + { + id: 'cicd-pipeline', + name: 'CI/CD Pipeline', + description: 'Lint → Unit test → Build → Integration test → Security audit → Deploy → Verify', + complexityLevel: 5, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Run linter, type checker, security scan. Gate: zero errors.' }, + { id: 'unit-test', agent: 'agentic', role: 'Unit Test Bee', prompt: 'Run unit tests across multiple environments. Gate: all pass, coverage ≥ 80%.' }, + { id: 'build', agent: 'agentic', role: 'Build Bee', prompt: 'Compile, package, upload artifact. Gate: build succeeds.' }, + { id: 'integration', agent: 'agentic', role: 'Integration Bee', prompt: 'Deploy to staging, run integration tests, smoke test.' }, + { id: 'security-audit', agent: 'agentic', role: 'Security Bee', prompt: 'Dependency vulnerability scan, container scan, compliance check.' }, + { id: 'deploy', agent: 'agentic', role: 'Deploy Bee', prompt: 'Rollout with health check. Gate: health passes.' }, + { id: 'verify', agent: 'DeepReview', role: 'Verify Bee', prompt: 'Smoke test production, monitor metrics, rollback if needed.', gate: true }, + ], + edges: [ + { from: 'lint', to: 'unit-test' }, + { from: 'unit-test', to: 'build' }, + { from: 'build', to: 'integration' }, + { from: 'integration', to: 'security-audit' }, + { from: 'security-audit', to: 'deploy' }, + { from: 'deploy', to: 'verify' }, + ], + }, + { + id: 'fan-out-converge', + name: 'Fan-out Converge', + description: 'Dispatch → Parallel research (N bees) → Synthesize → Final review', + complexityLevel: 5, + nodes: [ + { id: 'dispatch', agent: 'Team', role: 'Commander', prompt: 'Evaluate task, match pattern, build team, assign sub-goals.' }, + { id: 'researcher-1', agent: 'agentic', role: 'Research Bee A', prompt: 'Research scope A independently and report structured results.' }, + { id: 'researcher-2', agent: 'agentic', role: 'Research Bee B', prompt: 'Research scope B independently and report structured results.' }, + { id: 'researcher-3', agent: 'agentic', role: 'Research Bee C', prompt: 'Research scope C independently and report structured results.' }, + { id: 'synthesizer', agent: 'agentic', role: 'Synthesize Bee', prompt: 'Collect results, resolve conflicts, merge outputs, check consistency.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review merged output, generate final report.', gate: true }, + ], + edges: [ + { from: 'dispatch', to: 'researcher-1' }, + { from: 'dispatch', to: 'researcher-2' }, + { from: 'dispatch', to: 'researcher-3' }, + { from: 'researcher-1', to: 'synthesizer' }, + { from: 'researcher-2', to: 'synthesizer' }, + { from: 'researcher-3', to: 'synthesizer' }, + { from: 'synthesizer', to: 'reviewer' }, + { from: 'reviewer', to: 'synthesizer', condition: 'fail' }, + ], + }, + { + id: 'triad-minimal', + name: 'Three-Bee Minimal', + description: 'Prompt Bee → Execute Bee → Review Bee. Atomic execution unit.', + complexityLevel: 2, + nodes: [ + { id: 'prompt-bee', agent: 'Plan', role: 'Prompt Bee', prompt: 'Analyze task, inject relevant skills and templates.' }, + { id: 'execute-bee', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute the task using the provided methodology.' }, + { id: 'review-bee', agent: 'DeepReview', role: 'Review Bee', prompt: 'Audit behavior and output, gate pass/fail.', gate: true }, + ], + edges: [ + { from: 'prompt-bee', to: 'execute-bee' }, + { from: 'execute-bee', to: 'review-bee' }, + { from: 'review-bee', to: 'execute-bee', condition: 'fail' }, + { from: 'review-bee', to: 'prompt-bee', condition: 'fail' }, + ], + }, + { + id: 'state-machine', + name: 'State Machine', + description: 'Multi-state flow with conditional branches and escalation.', + complexityLevel: 6, + nodes: [ + { id: 'pending', agent: 'Plan', role: 'Assess Bee', prompt: 'Evaluate task complexity and route to appropriate state.' }, + { id: 'executing', agent: 'agentic', role: 'Execute Bee', prompt: 'Execute. On success → review. On failure (≤3) → retry. On failure (>3) → escalate.' }, + { id: 'reviewing', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review. On pass → complete. On fix (≤3 rounds) → back to executing.' }, + { id: 'escalated', agent: 'Team', role: 'Escalation', prompt: 'Human-in-the-loop decision: confirm fix or abandon.' }, + { id: 'completed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate completion report.' }, + { id: 'failed', agent: 'agentic', role: 'Doc Bee', prompt: 'Generate failure report with root cause.' }, + ], + edges: [ + { from: 'pending', to: 'executing' }, + { from: 'executing', to: 'reviewing', condition: 'success' }, + { from: 'executing', to: 'failed', condition: 'exhausted' }, + { from: 'reviewing', to: 'completed', condition: 'pass' }, + { from: 'reviewing', to: 'executing', condition: 'fix' }, + { from: 'reviewing', to: 'escalated', condition: 'max_rounds' }, + { from: 'escalated', to: 'executing', condition: 'confirm' }, + { from: 'escalated', to: 'failed', condition: 'abandon' }, + ], + }, + { + id: 'deep-research', + name: 'Deep Research', + description: '6-phase research pipeline with parallel specialists, debate, and arbitration.', + complexityLevel: 6, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Query understanding, ambiguity detection, sub-question decomposition.' }, + { id: 'primary', agent: 'agentic', role: 'Primary Source', prompt: 'Primary source specialist research.' }, + { id: 'news', agent: 'agentic', role: 'News Specialist', prompt: 'News and timeline research.' }, + { id: 'expert', agent: 'agentic', role: 'Expert Opinion', prompt: 'Expert opinion research.' }, + { id: 'counter', agent: 'agentic', role: 'Counter Evidence', prompt: 'Counter-evidence research.' }, + { id: 'advocate', agent: 'agentic', role: 'Advocate', prompt: 'Defend findings in adversarial debate.' }, + { id: 'critic', agent: 'agentic', role: 'Critic', prompt: 'Challenge findings in adversarial debate.' }, + { id: 'fact-checker', agent: 'agentic', role: 'Fact Checker', prompt: 'Resolve conflicts into HARD_CONFLICT / GENUINE_UNCERTAINTY / UNVERIFIED.' }, + { id: 'arbitrator', agent: 'DeepReview', role: 'Arbitrator', prompt: 'Research Manager arbitration with verdict markers.', gate: true }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate final report with citation index.' }, + ], + edges: [ + { from: 'planner', to: 'primary' }, + { from: 'planner', to: 'news' }, + { from: 'planner', to: 'expert' }, + { from: 'planner', to: 'counter' }, + { from: 'primary', to: 'advocate' }, + { from: 'news', to: 'advocate' }, + { from: 'expert', to: 'advocate' }, + { from: 'counter', to: 'critic' }, + { from: 'advocate', to: 'fact-checker' }, + { from: 'critic', to: 'fact-checker' }, + { from: 'fact-checker', to: 'arbitrator' }, + { from: 'arbitrator', to: 'reporter', condition: 'pass' }, + { from: 'arbitrator', to: 'fact-checker', condition: 'contest' }, + ], + }, + { + id: 'react-loop', + name: 'ReAct Loop', + description: 'Thought → Action → Observation loop with stop condition.', + complexityLevel: 1, + nodes: [ + { id: 'react-agent', agent: 'agentic', role: 'ReAct Agent', prompt: 'Think → Act → Observe loop until stop condition or final answer.' }, + ], + edges: [], + }, + { + id: 'plan-exec-reflect', + name: 'Plan-Execute-Reflect', + description: 'Plan → Execute step by step → Draft → Reflect and critique → Refine or stop.', + complexityLevel: 3, + nodes: [ + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create a structured plan with dependencies.' }, + { id: 'executor', agent: 'agentic', role: 'Executor', prompt: 'Execute the plan step by step.' }, + { id: 'reflector', agent: 'DeepReview', role: 'Reflector', prompt: 'Reflect on the draft, critique quality, decide refine or stop.', gate: true }, + ], + edges: [ + { from: 'planner', to: 'executor' }, + { from: 'executor', to: 'reflector' }, + { from: 'reflector', to: 'executor', condition: 'refine' }, + ], + }, + { + id: 'event-driven', + name: 'Event-Driven Response', + description: 'Detect → Classify → Triage → Resolve → Postmortem. For incidents and alerts.', + complexityLevel: 4, + nodes: [ + { id: 'detector', agent: 'agentic', role: 'Detector', prompt: 'Detect event source, classify severity (P0-P4), tag category.' }, + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Assess impact, identify root cause, propose fix.' }, + { id: 'resolver', agent: 'agentic', role: 'Resolver', prompt: 'Apply fix, verify resolution, restore service.' }, + { id: 'postmortem', agent: 'agentic', role: 'Postmortem', prompt: 'Document timeline, identify prevention, generate report.' }, + ], + edges: [ + { from: 'detector', to: 'triage' }, + { from: 'triage', to: 'resolver' }, + { from: 'resolver', to: 'postmortem' }, + ], + }, + { + id: 'coding-agent', + name: 'Coding Agent', + description: 'Repo inspection → Scoped plan → File edits → Tests & checks → Patch & summary.', + complexityLevel: 3, + nodes: [ + { id: 'inspector', agent: 'agentic', role: 'Inspector', prompt: 'Inspect repository structure, understand codebase.' }, + { id: 'planner', agent: 'Plan', role: 'Planner', prompt: 'Create scoped implementation plan.' }, + { id: 'editor', agent: 'agentic', role: 'Editor', prompt: 'Implement changes with minimal diff.' }, + { id: 'tester', agent: 'agentic', role: 'Tester', prompt: 'Run tests and checks.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Reviewer', prompt: 'Review diff, logs, summary.', gate: true }, + ], + edges: [ + { from: 'inspector', to: 'planner' }, + { from: 'planner', to: 'editor' }, + { from: 'editor', to: 'tester' }, + { from: 'tester', to: 'reviewer' }, + { from: 'reviewer', to: 'editor', condition: 'fail' }, + ], + }, + { + id: 'dag-data-pipeline', + name: 'DAG Data Pipeline', + description: 'Extract → Transform (parallel partitions) → Validate → Load → Report.', + complexityLevel: 4, + nodes: [ + { id: 'extract', agent: 'agentic', role: 'Extractor', prompt: 'Connect source, validate connection, pull incremental data.' }, + { id: 'transform-a', agent: 'agentic', role: 'Transform A', prompt: 'Clean and transform partition A.' }, + { id: 'transform-b', agent: 'agentic', role: 'Transform B', prompt: 'Clean and transform partition B.' }, + { id: 'validator', agent: 'agentic', role: 'Validator', prompt: 'Run quality rules, check anomalies, generate quality report.' }, + { id: 'loader', agent: 'agentic', role: 'Loader', prompt: 'Connect target, write data, verify row count.' }, + { id: 'reporter', agent: 'agentic', role: 'Reporter', prompt: 'Generate execution report, log metrics.' }, + ], + edges: [ + { from: 'extract', to: 'transform-a' }, + { from: 'extract', to: 'transform-b' }, + { from: 'transform-a', to: 'validator' }, + { from: 'transform-b', to: 'validator' }, + { from: 'validator', to: 'loader' }, + { from: 'loader', to: 'reporter' }, + ], + }, + { + id: 'pr-code-review', + name: 'PR Code Review', + description: 'PR created → Lint → Code review (max 3 rounds) → Merge → Deploy.', + complexityLevel: 3, + nodes: [ + { id: 'lint', agent: 'agentic', role: 'Lint Bee', prompt: 'Check diff size, run automated lint, verify PR template.' }, + { id: 'reviewer', agent: 'DeepReview', role: 'Review Bee', prompt: 'Review logic, check test coverage, verify no regression.' }, + { id: 'merger', agent: 'agentic', role: 'Merge Bee', prompt: 'Rebase, resolve conflicts, run CI again.' }, + { id: 'deployer', agent: 'agentic', role: 'Deploy Bee', prompt: 'Deploy with promotion staging → production.' }, + ], + edges: [ + { from: 'lint', to: 'reviewer' }, + { from: 'reviewer', to: 'merger', condition: 'approved' }, + { from: 'reviewer', to: 'lint', condition: 'changes_requested' }, + { from: 'merger', to: 'deployer' }, + ], + }, + { + id: 'deploy-orchestration', + name: 'Deploy Orchestration', + description: 'Configure → Schedule → Health check → Rolling update → Self-heal loop.', + complexityLevel: 5, + nodes: [ + { id: 'configure', agent: 'agentic', role: 'Config Bee', prompt: 'Define desired state, set resource limits, configure probes.' }, + { id: 'scheduler', agent: 'agentic', role: 'Schedule Bee', prompt: 'Match nodes, pull images, start containers.' }, + { id: 'health-check', agent: 'agentic', role: 'Health Bee', prompt: 'Readiness, liveness, startup probes.' }, + { id: 'updater', agent: 'agentic', role: 'Update Bee', prompt: 'Rolling update, verify each batch, zero downtime.' }, + { id: 'healer', agent: 'agentic', role: 'Healer Bee', prompt: 'Continuous pod/node health monitoring, auto-restart/scale/migrate.' }, + ], + edges: [ + { from: 'configure', to: 'scheduler' }, + { from: 'scheduler', to: 'health-check' }, + { from: 'health-check', to: 'updater' }, + { from: 'updater', to: 'healer' }, + ], + }, + { + id: 'six-layer-runtime', + name: 'Six-Layer Agent Runtime', + description: 'Intent dispatch → State & memory → Execution sandbox → Tool boundary → Control → Endpoint.', + complexityLevel: 7, + nodes: [ + { id: 'intent', agent: 'Team', role: 'Intent Layer', prompt: 'Receive task/event, dispatch to appropriate handler, spawn sub-agents.' }, + { id: 'state', agent: 'agentic', role: 'State Layer', prompt: 'Manage working memory, persist artifacts, create checkpoints.' }, + { id: 'exec', agent: 'agentic', role: 'Exec Layer', prompt: 'Execute in sandbox/container with appropriate environment.' }, + { id: 'tool', agent: 'agentic', role: 'Tool Layer', prompt: 'Bridge to MCP/A2A/ANP protocols, call external tools.' }, + { id: 'control', agent: 'DeepReview', role: 'Control Layer', prompt: 'Policy approval, behavior evaluation, guard enforcement.' }, + { id: 'endpoint', agent: 'agentic', role: 'Endpoint Layer', prompt: 'Deliver results to user interface or API consumer.' }, + ], + edges: [ + { from: 'intent', to: 'state' }, + { from: 'state', to: 'exec' }, + { from: 'exec', to: 'tool' }, + { from: 'tool', to: 'control' }, + { from: 'control', to: 'endpoint' }, + ], + }, + { + id: 'memory-retrieval', + name: 'Memory & Retrieval', + description: 'Working memory → Promote/discard → Episodic/Semantic memory → Retrieval → Notes → Task context.', + complexityLevel: 4, + nodes: [ + { id: 'working', agent: 'agentic', role: 'Working Memory', prompt: 'Current session state, lightweight, in-process.' }, + { id: 'episodic', agent: 'agentic', role: 'Episodic Store', prompt: 'Store bounded events with structured metadata + similarity search.' }, + { id: 'semantic', agent: 'agentic', role: 'Semantic Store', prompt: 'Persist cross-task facts, dedup, normalize relations.' }, + { id: 'retrieval', agent: 'agentic', role: 'Retrieval Layer', prompt: 'Hybrid search: keyword + dense retrieval + structured filters.' }, + { id: 'context', agent: 'agentic', role: 'Context Builder', prompt: 'Assemble notes and artifacts into task context for model call.' }, + ], + edges: [ + { from: 'working', to: 'episodic' }, + { from: 'working', to: 'semantic' }, + { from: 'episodic', to: 'retrieval' }, + { from: 'semantic', to: 'retrieval' }, + { from: 'retrieval', to: 'context' }, + ], + }, + { + id: 'customer-support', + name: 'Customer Support', + description: 'Triage → Policy grounding → Draft → Guardrails → Human review queue.', + complexityLevel: 3, + nodes: [ + { id: 'triage', agent: 'agentic', role: 'Triage', prompt: 'Classify case type, urgency, sentiment, requested outcome.' }, + { id: 'policy', agent: 'agentic', role: 'Policy Agent', prompt: 'Ground response in explicit policy documents.' }, + { id: 'drafter', agent: 'agentic', role: 'Drafter', prompt: 'Draft response. Never auto-send — final decision is human.' }, + { id: 'guard', agent: 'DeepReview', role: 'Guardrail', prompt: 'Reject refunds, legal commitments, high-risk actions.', gate: true }, + ], + edges: [ + { from: 'triage', to: 'policy' }, + { from: 'policy', to: 'drafter' }, + { from: 'drafter', to: 'guard' }, + { from: 'guard', to: 'drafter', condition: 'fail' }, + ], + }, + { + id: 'evaluation-observability', + name: 'Evaluation & Observability', + description: 'Offline eval → Online monitoring → Structured traces → Failure triage.', + complexityLevel: 5, + nodes: [ + { id: 'offline', agent: 'agentic', role: 'Offline Eval', prompt: 'Run benchmarks on known tasks, compare prompts/models/tools.' }, + { id: 'online', agent: 'agentic', role: 'Online Monitor', prompt: 'Collect production signals: success rate, latency, escalation rate.' }, + { id: 'tracer', agent: 'agentic', role: 'Tracer', prompt: 'Capture structured traces: tool inputs/outputs, state transitions.' }, + { id: 'triage', agent: 'DeepReview', role: 'Triage', prompt: 'Failure triage from traces: prompt / tool / model decisions.' }, + ], + edges: [ + { from: 'offline', to: 'triage' }, + { from: 'online', to: 'triage' }, + { from: 'tracer', to: 'triage' }, + ], + }, + { + id: 'workflow-agent-hybrid', + name: 'Workflow-Agent Hybrid', + description: 'Known path → workflow. Unknown path → agent. Hybrid embeds agent nodes in workflow or vice versa.', + complexityLevel: 5, + nodes: [ + { id: 'classifier', agent: 'Plan', role: 'Classifier', prompt: 'Evaluate: is the path known and rules stable (workflow) or unknown/variable (agent)?' }, + { id: 'workflow', agent: 'agentic', role: 'Workflow', prompt: 'Predefined ordered execution for deterministic business logic.' }, + { id: 'agent-node', agent: 'agentic', role: 'Agent Node', prompt: 'Autonomous decision-making for bounded exploration and judgment.' }, + { id: 'compliance', agent: 'DeepReview', role: 'Compliance', prompt: 'Wrap agent outputs in workflow controls: compliance, approval, irreversible ops.', gate: true }, + ], + edges: [ + { from: 'classifier', to: 'workflow' }, + { from: 'classifier', to: 'agent-node' }, + { from: 'agent-node', to: 'compliance' }, + { from: 'workflow', to: 'compliance' }, + ], + }, +]; + +export default PATTERNS; From 185eb8c3b1cfacf605956060521e06db35c27e1b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:31:59 +0800 Subject: [PATCH 24/48] fix-unsafe-warnings --- .../src/computer_use/windows_capture.rs | 8 +- .../src/computer_use/windows_wgc_capture.rs | 12 +- .../src/platform/evaluator/windows.rs | 4 +- .../agentic/agents/definitions/modes/team.rs | 1 + .../agents/definitions/subagents/acp_agent.rs | 79 +++ .../agents/definitions/subagents/mod.rs | 2 + .../assembly/core/src/agentic/agents/mod.rs | 3 +- .../src/agentic/agents/prompts/acp_agent.md | 17 + .../core/src/agentic/agents/registry/mod.rs | 5 + .../implementations/legion_control_tool.rs | 463 ++++++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 1 + .../execution/tool-provider-groups/src/lib.rs | 2 +- .../interfaces/acp/src/client/manager.rs | 28 ++ 14 files changed, 613 insertions(+), 14 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs diff --git a/src/apps/desktop/src/computer_use/windows_capture.rs b/src/apps/desktop/src/computer_use/windows_capture.rs index b2715086e7..1d0832b2ac 100644 --- a/src/apps/desktop/src/computer_use/windows_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_capture.rs @@ -200,7 +200,7 @@ fn screenshot_window_via_wgc(hwnd: HWND) -> BitFunResult<(Vec, u32, u32)> { /// Works for UWP / DirectComposition surfaces that `PrintWindow` can't reach, /// as long as the window is on-screen and not occluded. Returns /// `(bgra_pixels, width, height)`. -unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { +unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { unsafe { let mut rect = RECT::default(); // SAFETY: `rect` is a valid out-parameter and a stale/invalid HWND is // reported by the Win32 API. @@ -280,7 +280,7 @@ unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32 )); } Ok((pixels, w, h)) -} +}} /// A captured window bitmap plus the screen-space geometry it maps to. /// @@ -317,7 +317,7 @@ pub(super) fn screenshot_window_capture(hwnd: HWND) -> BitFunResult BitFunResult { +unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult { unsafe { if hwnd.is_invalid() { return Err(BitFunError::service( "screenshot_window_bytes: invalid HWND", @@ -494,7 +494,7 @@ unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult BitFunResult<(Vec, u32, u32 } } -unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { +unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { unsafe { let mut device: Option = None; let mut context: Option = None; let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; @@ -143,9 +143,9 @@ unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceConte BitFunError::service("D3D11CreateDevice returned null context".to_string()) })?; Ok((device, context)) -} +}} -unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { +unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { unsafe { let dxgi_device: IDXGIDevice = d3d_device .cast() .map_err(|e| BitFunError::service(format!("IDXGIDevice cast: {e}")))?; @@ -156,13 +156,13 @@ unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult BitFunResult<(Vec, u32, u32)> { +) -> BitFunResult<(Vec, u32, u32)> { unsafe { let surface = frame .Surface() .map_err(|e| BitFunError::service(format!("WGC frame Surface: {e}")))?; @@ -227,6 +227,6 @@ unsafe fn copy_frame_to_bgra( unsafe { d3d_context.Unmap(&staging, 0) }; Ok((pixels, width, height)) -} +}} const D3D11_SDK_VERSION: u32 = 7; diff --git a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs index 6626ad1835..59ebd9c36d 100644 --- a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs @@ -170,7 +170,7 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand } } -unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { +unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { unsafe { let handler: ICoreWebView2WebMessageReceivedEventHandler = WebMessageReceivedHandler.into(); // SAFETY: `EventRegistrationToken` is an FFI value initialized by WebView2, // and both COM interface references remain valid for the duration of the call. @@ -183,7 +183,7 @@ unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDri std::mem::forget(handler); Ok(()) -} +}} fn parse_message_payload(message: &str) -> Option { if let Ok(inner) = serde_json::from_str::(message) { diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 47c119d777..9c880f5245 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -46,6 +46,7 @@ impl TeamMode { "get_goal".to_string(), "create_goal".to_string(), "update_goal".to_string(), + "LegionControl".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs new file mode 100644 index 0000000000..190aacf892 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -0,0 +1,79 @@ +//! ACP bridge agent — an AgentRegistry entry for every configured ACP client. +//! +//! Each ACP client (OpenCode, Claude Code, CodeBuddy, etc.) is represented as a +//! `SubAgent` so it appears in the agent selector and can be targeted by +//! `SessionControl` / `SessionMessage` for legion orchestration. + +use crate::agentic::agents::{Agent, UserContextPolicy}; +use async_trait::async_trait; +use bitfun_agent_tools::build_acp_external_agent_tool_name; + +/// A thin Agent wrapper around a single ACP client config. +#[allow(dead_code)] +pub struct AcpAgent { + agent_id: String, + tool_name: String, + display_name: String, + default_tools: Vec, +} + +impl AcpAgent { + pub fn new(client_id: String, display_name: String) -> Self { + let agent_id = Self::agent_id_for(&client_id); + let tool_name = build_acp_external_agent_tool_name(&client_id); + Self { + default_tools: vec![ + tool_name.clone(), + "Read".to_string(), + "Grep".to_string(), + "Glob".to_string(), + "LS".to_string(), + ], + agent_id, + tool_name, + display_name, + } + } + + /// The agent registry id: `acp__` + pub fn agent_id_for(client_id: &str) -> String { + format!("acp__{client_id}") + } +} + +#[async_trait] +impl Agent for AcpAgent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + &self.agent_id + } + + fn name(&self) -> &str { + &self.display_name + } + + fn description(&self) -> &str { + "External ACP agent — delegates tasks to a connected ACP-compatible client." + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "acp_agent" + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn user_context_policy(&self) -> UserContextPolicy { + UserContextPolicy::empty() + .with_workspace_context() + .with_workspace_instructions() + } + + fn is_readonly(&self) -> bool { + false + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs index 37309c8e3f..3b18284957 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/mod.rs @@ -1,9 +1,11 @@ +mod acp_agent; mod computer_use; mod explore; mod file_finder; mod general_purpose; mod research_specialist; +pub use acp_agent::AcpAgent; pub use computer_use::ComputerUseMode; pub use explore::ExploreAgent; pub use file_finder::FileFinderAgent; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index fe62ad94ba..8a5117cb4a 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -35,7 +35,8 @@ pub use definitions::review::{ }; pub use definitions::shared::ReadonlySubagent; pub use definitions::subagents::{ - ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, ResearchSpecialistAgent, + AcpAgent, ComputerUseMode, ExploreAgent, FileFinderAgent, GeneralPurposeAgent, + ResearchSpecialistAgent, }; use indexmap::IndexMap; pub use prompt_builder::{ diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md new file mode 100644 index 0000000000..08d0629d7b --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/prompts/acp_agent.md @@ -0,0 +1,17 @@ +You are a bridge to an external ACP agent running inside BitFun. A commander has delegated a task to you. Your job is to forward the task through your ACP tool and return the result, nothing more. + +## How You Work + +1. Read the task that was sent to you via SessionMessage. +2. Call your ACP prompt tool with the task as the `prompt` parameter. +3. Return the ACP agent's response exactly as received — do not summarise, reinterpret, or embellish. +4. If the ACP tool returns an error, report the error with the original task context so the commander can decide how to proceed. + +## Constraints + +- You do NOT have file write or edit capabilities by default. Your only execution tool is the ACP bridge. +- Do NOT ask the user questions. The commander is your only audience. +- Be concise. The commander is managing many agents and needs clear, direct responses. +- Do NOT pretend to perform work that should be delegated through the ACP tool. + +{LANGUAGE_PREFERENCE} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 9de2e41697..188e156b59 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -116,6 +116,11 @@ impl AgentRegistry { .and_then(|entries| entries.get(agent_type).cloned()) } + /// Remove an agent entry by id (e.g. when an ACP client is disabled/removed). + pub fn unregister_agent(&self, id: &str) { + self.write_agents().remove(id); + } + /// Get a agent by ID (searches all categories including hidden) pub fn get_agent( &self, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs new file mode 100644 index 0000000000..d7e7561016 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -0,0 +1,463 @@ +//! LegionControl tool — load and instantiate legion templates. +//! +//! Reads `.bitfun/config/legions/.json`, topologically sorts nodes by +//! edges, creates each node via `SessionControl` path, and returns the +//! created session list. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::tools::framework::{ + Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_runtime_ports::AgentSessionCreateRequest; +use serde::Deserialize; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::path::PathBuf; + +/// JSON format of a legion template file. +#[derive(Debug, Deserialize)] +struct LegionTemplate { + id: String, + name: String, + #[serde(default)] + nodes: Vec, + #[serde(default)] + edges: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +#[allow(dead_code)] +struct LegionNode { + id: String, + agent: String, + #[serde(default)] + role: String, + #[serde(default)] + prompt: String, +} + +#[derive(Debug, Deserialize)] +struct LegionEdge { + from: String, + to: String, + #[serde(default)] + condition: String, +} + +#[derive(Debug, Deserialize)] +struct LegionControlInput { + action: String, + legion_id: String, + #[serde(default)] + workspace: Option, +} + +pub struct LegionControlTool; + +impl LegionControlTool { + pub fn new() -> Self { + Self + } + + fn config_dir(&self) -> PathBuf { + let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); + home.join(".bitfun").join("config").join("legions") + } + + fn legion_path(&self, legion_id: &str) -> PathBuf { + self.config_dir().join(format!("{}.json", legion_id)) + } +} + +#[async_trait] +impl Tool for LegionControlTool { + fn name(&self) -> &str { + "LegionControl" + } + + async fn description(&self) -> BitFunResult { + Ok(concat!( + "Load and instantiate a legion template.\n\n", + "Actions:\n", + "- load: Read a legion template from `.bitfun/config/legions/.json`, ", + "topologically sort nodes by edges, create each node as a new agent session ", + "via SessionControl, and return the created session list.\n", + "- list: List available legion templates.\n\n", + "Parameters:\n", + "- action: \"load\" or \"list\"\n", + "- legion_id: template id (required for load)\n", + "- workspace: override workspace path (optional, defaults to current workspace)", + ) + .to_string()) + } + + fn short_description(&self) -> String { + "Load and instantiate legion templates with automatic topology-sorted node creation." + .to_string() + } + + fn default_exposure(&self) -> ToolExposure { + ToolExposure::Expanded + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["load", "list"] + }, + "legion_id": { + "type": "string", + "description": "Legion template id (required for load action)" + }, + "workspace": { + "type": "string", + "description": "Override workspace path" + } + }, + "required": ["action"], + "additionalProperties": false + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match serde_json::from_value::(input.clone()) { + Ok(params) => { + if params.action == "load" && params.legion_id.is_empty() { + return ValidationResult { + result: false, + message: Some("legion_id is required for load action".to_string()), + error_code: None, + meta: None, + }; + } + ValidationResult::default() + } + Err(e) => ValidationResult { + result: false, + message: Some(format!("Invalid input: {}", e)), + error_code: None, + meta: None, + }, + } + } + + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { + let action = input + .get("action") + .and_then(Value::as_str) + .unwrap_or("?"); + let legion_id = input + .get("legion_id") + .and_then(Value::as_str) + .unwrap_or("?"); + format!("LegionControl {} {}", action, legion_id) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let params: LegionControlInput = serde_json::from_value(input.clone()) + .map_err(|e| BitFunError::tool(format!("Invalid input: {}", e)))?; + + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; + let runtime = CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) + .map_err(BitFunError::tool)?; + + match params.action.as_str() { + "list" => self.list_legions().await, + "load" => { + self.load_and_instantiate(¶ms, context, &runtime) + .await + } + _ => Err(BitFunError::tool(format!( + "Unknown action: {}. Supported: load, list", + params.action + ))), + } + } +} + +impl LegionControlTool { + async fn list_legions(&self) -> BitFunResult> { + let dir = self.config_dir(); + let mut names: Vec = Vec::new(); + + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + names.push(stem.to_string()); + } + } + } + } + + let result_text = if names.is_empty() { + format!( + "No legion templates found in {}", + dir.display() + ) + } else { + let mut lines = vec!["Available legion templates:".to_string()]; + for name in &names { + lines.push(format!("- {}", name)); + } + lines.join("\n") + }; + + Ok(vec![ToolResult::Result { + data: json!({ "legions": names }), + result_for_assistant: Some(result_text), + image_attachments: None, + }]) + } + + async fn load_and_instantiate( + &self, + params: &LegionControlInput, + context: &ToolUseContext, + runtime: &bitfun_agent_runtime::sdk::AgentRuntime, + ) -> BitFunResult> { + let path = self.legion_path(¶ms.legion_id); + let content = std::fs::read_to_string(&path).map_err(|e| { + BitFunError::tool(format!( + "Failed to read legion template {}: {}", + path.display(), + e + )) + })?; + + let template: LegionTemplate = serde_json::from_str(&content).map_err(|e| { + BitFunError::tool(format!("Invalid legion template: {}", e)) + })?; + + if template.nodes.is_empty() { + return Err(BitFunError::tool("Legion template has no nodes".to_string())); + } + + // Determine workspace + let workspace = params + .workspace + .clone() + .or_else(|| context.workspace_root().map(|p| p.to_string_lossy().to_string())) + .ok_or_else(|| BitFunError::tool("workspace is required".to_string()))?; + + // Topological sort + let sorted_ids = topological_sort(&template.nodes, &template.edges)?; + + // Build node lookup + let node_map: HashMap<&str, &LegionNode> = + template.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); + + // Create sessions in topological order + let mut created_sessions: Vec = Vec::new(); + let mut session_lines: Vec = vec![format!( + "## Legion: {} ({})", + template.name, template.id + )]; + session_lines.push(format!("Workspace: {}", workspace)); + session_lines.push(String::new()); + + // Batch create: group independent nodes per topological layer + let layers = build_topological_layers(&sorted_ids, &template.edges); + for (layer_idx, layer) in layers.iter().enumerate() { + if layer.len() > 1 { + session_lines.push(format!( + "**Layer {} ({} parallel nodes):**", + layer_idx + 1, + layer.len() + )); + } + + for node_id in layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + + let session_name = if node.role.is_empty() { + format!("{}-{}", template.name, node.id) + } else { + format!("{}-{}", template.name, node.role) + }; + + let session = runtime + .create_session(AgentSessionCreateRequest { + session_name, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + metadata: { + let mut meta = serde_json::Map::new(); + meta.insert( + "legionId".to_string(), + json!(template.id), + ); + meta.insert( + "legionNodeId".to_string(), + json!(node.id), + ); + meta.insert( + "legionRole".to_string(), + json!(node.role), + ); + meta + }, + }) + .await + .map_err(|error| { + BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) + })?; + + let entry = json!({ + "node_id": node.id, + "role": node.role, + "agent_type": node.agent, + "session_id": session.session_id, + "session_name": session.session_name, + }); + created_sessions.push(entry); + + session_lines.push(format!( + "- **{}** ({}) → session `{}` (agent: {})", + node.role, node.id, session.session_id, node.agent + )); + } + + if layer.len() > 1 { + session_lines.push(String::new()); + } + } + + // Append edge structure + if !template.edges.is_empty() { + session_lines.push(String::from("### Edges")); + for edge in &template.edges { + let cond = if edge.condition.is_empty() { + String::new() + } else { + format!(" [condition: {}]", edge.condition) + }; + session_lines.push(format!("- {} → {}{}", edge.from, edge.to, cond)); + } + } + + Ok(vec![ToolResult::Result { + data: json!({ + "legion_id": template.id, + "legion_name": template.name, + "nodes_created": created_sessions.len(), + "sessions": created_sessions, + }), + result_for_assistant: Some(session_lines.join("\n")), + image_attachments: None, + }]) + } +} + +/// Topological sort: nodes with no incoming edges first. +fn topological_sort( + nodes: &[LegionNode], + edges: &[LegionEdge], +) -> BitFunResult> { + let mut in_degree: HashMap<&str, usize> = HashMap::new(); + let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new(); + + for node in nodes { + in_degree.entry(node.id.as_str()).or_insert(0); + adjacency.entry(node.id.as_str()).or_default(); + } + + for edge in edges { + adjacency + .entry(edge.from.as_str()) + .or_default() + .push(edge.to.as_str()); + *in_degree.entry(edge.to.as_str()).or_insert(0) += 1; + } + + let mut queue: VecDeque<&str> = in_degree + .iter() + .filter(|(_, °)| deg == 0) + .map(|(&id, _)| id) + .collect(); + + let mut sorted: Vec = Vec::new(); + while let Some(node) = queue.pop_front() { + sorted.push(node.to_string()); + if let Some(neighbors) = adjacency.get(node) { + for &neighbor in neighbors { + if let Some(deg) = in_degree.get_mut(neighbor) { + *deg -= 1; + if *deg == 0 { + queue.push_back(neighbor); + } + } + } + } + } + + if sorted.len() != nodes.len() { + let node_ids: HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + let sorted_set: HashSet<&str> = sorted.iter().map(|s| s.as_str()).collect(); + let missing: Vec<_> = node_ids.difference(&sorted_set).collect(); + return Err(BitFunError::tool(format!( + "Cyclic dependency detected in legion edges. Unresolved nodes: {:?}", + missing + ))); + } + + Ok(sorted) +} + +/// Group topologically sorted nodes into layers where each layer can execute in parallel. +fn build_topological_layers( + sorted_ids: &[String], + edges: &[LegionEdge], +) -> Vec> { + let mut layers: Vec> = Vec::new(); + let mut assigned: HashMap<&str, usize> = HashMap::new(); + + for id in sorted_ids { + // Place node one layer after all its predecessors. + // Nodes with no predecessors land on layer 0. + let max_pred_layer = edges + .iter() + .filter(|e| e.to == *id) + .filter_map(|e| assigned.get(e.from.as_str())) + .max() + .copied(); + + let layer = match max_pred_layer { + Some(max_layer) => max_layer + 1, + None => 0, + }; + while layers.len() <= layer { + layers.push(Vec::new()); + } + layers[layer].push(id.clone()); + assigned.insert(id.as_str(), layer); + } + + layers.retain(|l| !l.is_empty()); + layers +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 81453cb82c..79d39ed797 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -26,6 +26,7 @@ pub mod get_time_tool; pub mod git_tool; pub mod glob_tool; pub mod grep_tool; +pub mod legion_control_tool; pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; @@ -67,6 +68,7 @@ pub use get_time_tool::GetTimeTool; pub use git_tool::GitTool; pub use glob_tool::GlobTool; pub use grep_tool::GrepTool; +pub use legion_control_tool::LegionControlTool; pub use ls_tool::LSTool; pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 77474acefb..cd360d3100 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -67,6 +67,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "ControlHub" => Some(Arc::new(ControlHubTool::new())), "ComputerUse" => Some(Arc::new(ComputerUseTool::new())), "Playbook" => Some(Arc::new(PlaybookTool::new())), + "LegionControl" => Some(Arc::new(LegionControlTool::new())), _ => None, } } diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 09c0adfce0..2851e2ff78 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -162,7 +162,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { provider_id: "core.session", feature_groups: CORE_SESSION_FEATURE_GROUPS, - tool_names: &["SessionControl", "SessionMessage", "SessionHistory", "Cron"], + tool_names: &["SessionControl", "SessionMessage", "SessionHistory", "Cron", "LegionControl"], }, ToolProviderGroupPlan { provider_id: "core.integration", diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 169e2258b6..3b313119bf 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1507,6 +1507,34 @@ impl AcpClientService { debug!("Registering ACP client tool: name={}", tool.name()); registry.register_tool(tool); } + drop(registry); + + // Also register each ACP client as a SubAgent in the global AgentRegistry + // so they appear in the agent selector and can be targeted by + // SessionControl / SessionMessage for legion orchestration. + let agent_registry = + bitfun_core::agentic::agents::get_agent_registry(); + for (client_id, config) in configs.iter().filter(|(_, c)| c.enabled) { + let agent_id = + bitfun_core::agentic::agents::AcpAgent::agent_id_for( + client_id, + ); + // Clean up any previous registration for this client + agent_registry.unregister_agent(&agent_id); + let agent = Arc::new( + bitfun_core::agentic::agents::AcpAgent::new( + client_id.clone(), + config.name.clone().unwrap_or_else(|| client_id.clone()), + ), + ); + agent_registry.register_agent( + agent, + bitfun_core::agentic::agents::AgentCategory::SubAgent, + bitfun_core::agentic::agents::AgentSource::Builtin, + Some(bitfun_core::agentic::agents::SubAgentSource::Builtin), + None, + ); + } } async fn handle_permission_request( From 47c93f0668b2d0b7158f4736b22edeff912f20ba Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:39:18 +0800 Subject: [PATCH 25/48] fix-legion-list --- .../src/agentic/tools/implementations/legion_control_tool.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index d7e7561016..e28cfa9824 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -50,6 +50,7 @@ struct LegionEdge { #[derive(Debug, Deserialize)] struct LegionControlInput { action: String, + #[serde(default)] legion_id: String, #[serde(default)] workspace: Option, From 227f074b4af4ad17c6f767b4da2972145af7ca71 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:52:44 +0800 Subject: [PATCH 26/48] fix-legion-topo-skip-conditional-edges --- .../agentic/tools/implementations/legion_control_tool.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index e28cfa9824..53d1c86cd5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -389,6 +389,11 @@ fn topological_sort( } for edge in edges { + // Skip conditional edges (fail/retry) — they are runtime routing, + // not compile-time dependencies for the DAG. + if !edge.condition.is_empty() { + continue; + } adjacency .entry(edge.from.as_str()) .or_default() @@ -443,7 +448,7 @@ fn build_topological_layers( // Nodes with no predecessors land on layer 0. let max_pred_layer = edges .iter() - .filter(|e| e.to == *id) + .filter(|e| e.to == *id && e.condition.is_empty()) .filter_map(|e| assigned.get(e.from.as_str())) .max() .copied(); From 7ca13e666da2985937118e2a76a362e7b11a2bda Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 19:58:15 +0800 Subject: [PATCH 27/48] acp-mode-and-legion-topo-fixes --- src/crates/interfaces/acp/src/client/manager.rs | 4 ++-- .../src/app/scenes/agents/hooks/useAgentsList.ts | 13 +++++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 3b313119bf..3764fa141b 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1529,9 +1529,9 @@ impl AcpClientService { ); agent_registry.register_agent( agent, - bitfun_core::agentic::agents::AgentCategory::SubAgent, + bitfun_core::agentic::agents::AgentCategory::Mode, bitfun_core::agentic::agents::AgentSource::Builtin, - Some(bitfun_core::agentic::agents::SubAgentSource::Builtin), + None, None, ); } diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 463ef75c6a..2af845f8f5 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -204,7 +204,7 @@ export function useAgentsList({ ]).catch((): Record => ({})), ]); - // Fetch enabled ACP external agents for the core zone. + // Fetch enabled ACP external agents for core zone metadata enrichment. let acpClients: AcpClientInfo[] = []; try { acpClients = (await ACPClientAPI.getClients()).filter((c) => c.enabled); @@ -284,9 +284,14 @@ export function useAgentsList({ }); }); - const acpAgents: AgentWithCapabilities[] = acpClients.map((client): AgentWithCapabilities => { + const modeAgentIds = new Set(modeAgents.map((a) => a.id)); + const acpAgents: AgentWithCapabilities[] = acpClients + .filter((client) => !modeAgentIds.has(`${ACP_CORE_AGENT_PREFIX}${client.id}`)) + .map((client): AgentWithCapabilities => { const agentId = `${ACP_CORE_AGENT_PREFIX}${client.id}`; const displayName = client.name || client.id; + const acpToolName = `${ACP_CORE_AGENT_PREFIX}${client.id}__prompt`; + const defaultTools = [acpToolName, 'Read', 'Grep', 'Glob', 'LS']; return enrichCapabilities({ key: `acp::${client.id}`, id: agentId, @@ -296,8 +301,8 @@ export function useAgentsList({ : `External ACP agent — ${client.status}`, isReadonly: client.readonly, isReview: false, - toolCount: 0, - defaultTools: [], + toolCount: defaultTools.length, + defaultTools, defaultEnabled: client.enabled, effectiveEnabled: client.enabled, source: 'builtin' as AgentSource, From 9e63ba9fb03f7661f052a2996e69442bd86eb41f Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:25:41 +0800 Subject: [PATCH 28/48] fix-acp-duplicate-tool-name --- .../src/agentic/agents/definitions/subagents/acp_agent.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs index 190aacf892..58452516d6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -6,13 +6,11 @@ use crate::agentic::agents::{Agent, UserContextPolicy}; use async_trait::async_trait; -use bitfun_agent_tools::build_acp_external_agent_tool_name; /// A thin Agent wrapper around a single ACP client config. #[allow(dead_code)] pub struct AcpAgent { agent_id: String, - tool_name: String, display_name: String, default_tools: Vec, } @@ -20,17 +18,17 @@ pub struct AcpAgent { impl AcpAgent { pub fn new(client_id: String, display_name: String) -> Self { let agent_id = Self::agent_id_for(&client_id); - let tool_name = build_acp_external_agent_tool_name(&client_id); Self { + // ACP prompt tool is registered in the global tool registry by + // register_configured_tools() — do NOT add it to default_tools + // here, or the tool name will appear twice in the model manifest. default_tools: vec![ - tool_name.clone(), "Read".to_string(), "Grep".to_string(), "Glob".to_string(), "LS".to_string(), ], agent_id, - tool_name, display_name, } } From d04ed00886e400d3a07708863f4a3648989d111b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:36:13 +0800 Subject: [PATCH 29/48] a --- .../core/src/agentic/agents/definitions/subagents/acp_agent.rs | 2 +- src/web-ui/src/locales/en-US/scenes/agents.json | 2 +- src/web-ui/src/locales/zh-CN/scenes/agents.json | 2 +- src/web-ui/src/locales/zh-TW/scenes/agents.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs index 58452516d6..5203ed90a6 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/subagents/acp_agent.rs @@ -54,7 +54,7 @@ impl Agent for AcpAgent { } fn description(&self) -> &str { - "External ACP agent — delegates tasks to a connected ACP-compatible client." + "ACP agent" } fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 000a6ad6bb..0ac4f27bed 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -43,7 +43,7 @@ "role": "Desktop automation agent" }, "acpExternal": { - "role": "External ACP agent — {{name}}" + "role": "ACP Agent" } } }, diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 42b5607124..9c840120f7 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -43,7 +43,7 @@ "role": "电脑操作智能体" }, "acpExternal": { - "role": "外部 ACP 智能体 — {{name}}" + "role": "ACP智能体" } } }, diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index b8c1a1c230..818ea26a06 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -43,7 +43,7 @@ "role": "電腦操作智能體" }, "acpExternal": { - "role": "外部 ACP 智能體 — {{name}}" + "role": "ACP智能體" } } }, From d146bb52d542a7b737c8819cbd8c489fb3f34f0e Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:40:45 +0800 Subject: [PATCH 30/48] feat: LegionControl auto-send + Gate Loop Protocol + behavior guard stale-strategy detection --- .../src/agentic/agents/prompts/team_mode.md | 55 ++++++++++++++ .../implementations/legion_control_tool.rs | 75 ++++++++++++++++++- .../core/src/agentic/tools/post_call_hooks.rs | 73 +++++++++++++++++- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md index ac7611052f..0be0119c22 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md +++ b/src/crates/assembly/core/src/agentic/agents/prompts/team_mode.md @@ -20,6 +20,8 @@ All implementation, file operations, commands, and code changes MUST be delegate | `SessionHistory(session_id)` | Export a legion member's transcript for review. Use before gate decisions. | | `Task(subagent_type, prompt, run_in_background)` | Dispatch a sub-agent for focused, scoped work inside a single session. | | `get_goal` / `create_goal` / `update_goal` | Track campaign progress. Status flows: pending → in-progress → complete. Use `update_goal` to mark blocking when stuck. | +| `LegionControl(action:"load", legion_id:"")` | One-click deployment. Reads a legion template, topologically sorts nodes, creates all sessions, and returns the session list. Use this before manual SessionControl when a matching template exists. | +| `LegionControl(action:"list")` | List available legion templates. # The Three-Bee Atomic Unit @@ -33,6 +35,23 @@ These three bees communicate directly via SessionMessage. They form an internal # Deployment Protocol +## 0. Quick Deploy with LegionControl + +If a legion template matches the task, deploy it with one call: + +``` +LegionControl(action:"load", legion_id:"") +``` + +This creates all sessions in topological order and returns the session list with node IDs, roles, and agent types. You get back: +- All session IDs organized by topological layer +- Edge structure (who depends on whom) +- Which nodes are gates + +Then proceed to Step 3 (Fan-Out) — skip Steps 1-2. + +If no template matches, use Steps 1-2 below to build the legion manually. + ## 1. Task Decomposition Analyze the user's request. Break it into independent subtasks. Each subtask that is atomic (cannot be meaningfully split further) is assigned to one agent session. @@ -85,6 +104,42 @@ When all subtasks pass their gates, mark the campaign complete: update_goal(status:"complete") ``` +# Gate Loop Protocol + +Each legion layer follows a strict gate loop. The loop runs per-layer until every node in that layer passes its gate, then the next layer begins. + +**Loop mechanics per layer:** + +1. **Dispatch**: Send task via SessionMessage to each node in the current layer. Include acceptance criteria. All dispatches in a single message for parallelism. + +2. **Collect**: Wait for all nodes to reply. Each SessionMessage auto-returns when the agent completes. + +3. **Inspect**: Use SessionHistory to read each node's full transcript. Do NOT rely on the agent's summary alone. + +4. **Gate Decision** per node: + - PASS: Node met all acceptance criteria, output verified, no behavioral violations. + - FAIL: Node skipped verification, edited without reading, failed tests, or produced invalid output. + +5. **Correct or Proceed**: + - If any node FAILs: Send SessionMessage with `[CORRECTION] `. Return to step 2 for that node. + - If all nodes PASS: Proceed to the next layer. + +6. **Loop Counter**: Track retry count per node. If a node fails 3 corrections without improvement, do NOT retry the same approach. Instead: + - Re-decompose the subtask differently + - Assign a different agent type + - Escalate to a sub-legion (Step 6) + +**Gate rules applied during inspection:** +- Did the node read relevant files before editing? (SessionHistory check) +- Did the node verify output? (test/check commands in transcript) +- Did the node change strategy after repeated tool failures? +- Are all acceptance criteria met with evidence? + +**Examples of FAIL decisions:** +- Agent called Edit on `src/foo.rs` but never called Read on `src/foo.rs` → FAIL: "Read the file before editing" +- Agent claimed "tests pass" but transcript shows no test command → FAIL: "Run tests and show output" +- Agent called Grep 4 times with the same failing pattern → FAIL: "Strategy stale. Try a different search approach or read the directory listing first" + # Fractal Nesting Any agent session you create is also capable of creating its own sub-sessions. A legion member stuck on a complex problem can itself become a commander. This is not a bug — it is the design. Each level only cares about the level directly below it. The topology is self-similar at every scale. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 53d1c86cd5..55e9e88509 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -4,14 +4,16 @@ //! edges, creates each node via `SessionControl` path, and returns the //! created session list. -use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler}; use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_runtime_ports::AgentSessionCreateRequest; +use bitfun_runtime_ports::{ + AgentDialogTurnRequest, AgentSessionCreateRequest, DialogSubmissionPolicy, DialogTriggerSource, +}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::{HashMap, HashSet, VecDeque}; @@ -54,6 +56,9 @@ struct LegionControlInput { legion_id: String, #[serde(default)] workspace: Option, + /// When true, send each first-layer node its prompt as the initial task message. + #[serde(default)] + send_initial_message: bool, } pub struct LegionControlTool; @@ -362,6 +367,72 @@ impl LegionControlTool { } } + // Auto-send initial task messages to first-layer nodes + if params.send_initial_message && !layers.is_empty() { + let scheduler = get_global_scheduler() + .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; + let dialog_runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( + coordinator.clone(), + scheduler, + ) + .map_err(BitFunError::tool)?; + + let first_layer = &layers[0]; + session_lines.push(String::new()); + session_lines.push(format!( + "Sent initial tasks to {} first-layer node(s)", + first_layer.len() + )); + + for node_id in first_layer { + let node = match node_map.get(node_id.as_str()) { + Some(n) => n, + None => continue, + }; + let entry = created_sessions + .iter() + .find(|e| e["node_id"].as_str() == Some(node.id.as_str())); + let session_id = match entry.and_then(|e| e["session_id"].as_str()) { + Some(sid) => sid.to_string(), + None => continue, + }; + + let task_message = if node.prompt.is_empty() { + format!("Execute your role: {}", node.role) + } else { + node.prompt.clone() + }; + + let _ = dialog_runtime + .submit_dialog_turn(AgentDialogTurnRequest { + session_id, + message: task_message.clone(), + original_message: None, + turn_id: None, + agent_type: node.agent.clone(), + workspace_path: Some(workspace.clone()), + remote_connection_id: None, + remote_ssh_host: None, + policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), + reply_route: None, + prepended_reminders: vec![], + attachments: vec![], + metadata: None, + }) + .await; + + session_lines.push(format!( + "- Sent to **{}**: {}", + node.role, + if node.prompt.len() > 80 { + format!("{}...", &node.prompt[..77]) + } else { + node.prompt.clone() + } + )); + } + } + Ok(vec![ToolResult::Result { data: json!({ "legion_id": template.id, diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index 09923908db..b69cf9adf5 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -10,6 +10,21 @@ use bitfun_agent_runtime::post_call_hooks::{ run_successful_tool_post_call_hooks, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::Value; +use std::collections::HashMap; +use std::sync::Mutex; + +/// Tracks consecutive same-tool calls per session for stale-strategy detection. +static STALE_TRACKER: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone, Default)] +struct StaleToolState { + last_tool: String, + consecutive_count: u32, +} + +/// Max consecutive same-tool calls before abort. +const STALE_STRATEGY_THRESHOLD: u32 = 3; struct CorePostCallHookExecutor; @@ -25,10 +40,62 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec fn behavior_guard( &mut self, - _tool_name: &str, - _input: &Value, - _context: &ToolUseContext, + tool_name: &str, + input: &Value, + context: &ToolUseContext, ) -> HookResult { + // 1. Stale strategy: detect repeated same-tool calls (dead-loop pattern). + if let Some(session_id) = &context.session_id { + if let Ok(mut tracker) = STALE_TRACKER.lock() { + let entry = tracker + .entry(session_id.clone()) + .or_insert_with(StaleToolState::default); + if entry.last_tool == tool_name { + entry.consecutive_count += 1; + } else { + entry.last_tool = tool_name.to_string(); + entry.consecutive_count = 1; + } + if entry.consecutive_count >= STALE_STRATEGY_THRESHOLD { + return HookResult::Abort { + reason: format!( + "Tool '{}' called {} consecutive times without strategy change", + tool_name, entry.consecutive_count + ), + fix_instruction: format!( + "Stop retrying {tool}. Read the previous results, identify the root failure cause, and choose a different approach. Do not call {tool} again without a changed strategy.", + tool = tool_name + ), + max_retries: 0, + }; + } + } + } + + // 2. File read-before-edit: warn when editing without prior context. + // Full enforcement requires session-level file-read state tracking; + // this guard provides a Should-level prompt injection point. + if matches!( + tool_name, + "Edit" | "Write" | "Delete" | "edit_file" | "write_file" | "delete_file" + ) { + if let Some(file_path) = input + .get("file_path") + .or_else(|| input.get("path")) + .and_then(Value::as_str) + { + // Guard: editing a file without a known read in this turn + // is a behavior smell — inject a soft constraint via + // the result-for-assistant path rather than aborting. + let _ = file_path; // reserved for read-state cross-check + } + } + + // 3. Exit code check: requires post-execution result inspection. + // Implemented at the tool pipeline level (exec_command.rs response + // handling), not in the post-call hook which fires before result + // inspection. Hook here serves as a future integration point. + HookResult::Continue } } From f56e0435a81910cbc4ca5df4df40ae794a597f5b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 13 Jul 2026 20:43:37 +0800 Subject: [PATCH 31/48] fix: LegionControl send_initial_message scope and metadata type --- .../src/agentic/tools/implementations/legion_control_tool.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 55e9e88509..52eb0ef93e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -369,6 +369,8 @@ impl LegionControlTool { // Auto-send initial task messages to first-layer nodes if params.send_initial_message && !layers.is_empty() { + let coordinator = get_global_coordinator() + .ok_or_else(|| BitFunError::tool("coordinator not initialized".to_string()))?; let scheduler = get_global_scheduler() .ok_or_else(|| BitFunError::tool("scheduler not initialized".to_string()))?; let dialog_runtime = CoreServiceAgentRuntime::agent_runtime_with_dialog_turns( @@ -417,7 +419,7 @@ impl LegionControlTool { reply_route: None, prepended_reminders: vec![], attachments: vec![], - metadata: None, + metadata: serde_json::Map::new(), }) .await; From 0ffd889a6591d9886cd9b1ed6f91edcb86bf5700 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:04:01 +0800 Subject: [PATCH 32/48] fix: replace deprecated theme color tokens in LegionCard.scss --- .../src/app/scenes/agents/components/LegionCard.scss | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss index 957aaf6cfb..925e15e3ea 100644 --- a/src/web-ui/src/app/scenes/agents/components/LegionCard.scss +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.scss @@ -5,12 +5,12 @@ gap: 10px; padding: 16px; border-radius: 10px; - background: var(--color-surface-card); - border: 1px solid var(--color-border-subtle); + background: var(--element-bg-soft); + border: 1px solid var(--border-subtle); transition: border-color 0.15s, box-shadow 0.15s; &:hover { - border-color: var(--color-border-accent); + border-color: var(--border-accent); box-shadow: 0 2px 8px rgb(0 0 0 / 0.06); } @@ -28,8 +28,8 @@ width: 36px; height: 36px; border-radius: 8px; - background: var(--color-surface-accent); - color: var(--color-text-accent); + background: var(--color-overlay-white-12); + color: var(--color-accent-500); display: flex; align-items: center; justify-content: center; @@ -94,6 +94,6 @@ align-items: center; gap: 4px; font-size: 11px; - color: var(--color-text-tertiary); + color: var(--color-text-muted); } } From 78cf860feac798a61fb3bdd281b32722bce114a9 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:11:26 +0800 Subject: [PATCH 33/48] fix: add default impl for behavior_guard() in trait for backward compatibility --- .../execution/agent-runtime/src/post_call_hooks.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index d1566f24fc..c3efae29a4 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -174,10 +174,12 @@ pub trait SuccessfulToolPostCallHookExecutor { fn behavior_guard( &mut self, - tool_name: &str, - input: &Value, - context: &C, - ) -> HookResult; + _tool_name: &str, + _input: &Value, + _context: &C, + ) -> HookResult { + HookResult::Continue + } } pub fn run_successful_tool_post_call_hooks( From 89c73a1b8061b13d2a9a96a6935ff9d1ed8cbb4b Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 07:47:56 +0800 Subject: [PATCH 34/48] chore: commit pending fixes - path security, rollback, i18n integration, stale tracker cleanup --- .../core/src/agentic/agents/team_presets.rs | 30 +++++-- .../implementations/legion_control_tool.rs | 68 +++++++++++++--- .../core/src/agentic/tools/post_call_hooks.rs | 12 +++ .../post_call_hook_execution_contracts.rs | 11 ++- .../agents/components/CreateLegionPage.tsx | 79 +++++++++++++------ .../scenes/agents/components/LegionCard.tsx | 25 +++--- .../api/service-api/LegionPresetAPI.ts | 45 +++++++++++ .../src/locales/en-US/scenes/agents.json | 28 +++++++ .../src/locales/zh-CN/scenes/agents.json | 28 +++++++ .../src/locales/zh-TW/scenes/agents.json | 28 +++++++ 10 files changed, 296 insertions(+), 58 deletions(-) create mode 100644 src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts diff --git a/src/crates/assembly/core/src/agentic/agents/team_presets.rs b/src/crates/assembly/core/src/agentic/agents/team_presets.rs index 067a911faa..7401f08518 100644 --- a/src/crates/assembly/core/src/agentic/agents/team_presets.rs +++ b/src/crates/assembly/core/src/agentic/agents/team_presets.rs @@ -44,8 +44,26 @@ fn legions_dir() -> PathBuf { get_path_manager_arc().user_config_dir().join(LEGIONS_SUBDIR) } -fn preset_path(id: &str) -> PathBuf { - legions_dir().join(format!("{id}.json")) +fn preset_path(id: &str) -> Result { + validate_preset_id(id)?; + Ok(legions_dir().join(format!("{id}.json"))) +} + +/// Validate preset id to prevent path traversal. +/// Allowed characters: alphanumeric, underscore, and hyphen. +fn validate_preset_id(id: &str) -> Result<(), String> { + if id.is_empty() { + return Err("Legion preset id must not be empty".to_string()); + } + if !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(format!( + "Invalid legion preset id '{id}': only letters, digits, underscores, and hyphens are allowed" + )); + } + Ok(()) } fn ensure_legions_dir() -> std::io::Result<()> { @@ -78,7 +96,7 @@ pub fn list_presets() -> Result, String> { /// Load a single preset by id. pub fn get_preset(id: &str) -> Result { - let path = preset_path(id); + let path = preset_path(id)?; if !path.is_file() { return Err(format!("Legion preset '{id}' not found")); } @@ -90,7 +108,7 @@ pub fn get_preset(id: &str) -> Result { /// Create or overwrite a preset. pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { ensure_legions_dir().map_err(|e| format!("Failed to create legions dir: {e}"))?; - let path = preset_path(&preset.id); + let path = preset_path(&preset.id)?; let raw = serde_json::to_string_pretty(preset).map_err(|e| format!("Failed to serialise: {e}"))?; std::fs::write(&path, raw).map_err(|e| format!("Failed to write preset: {e}")) @@ -98,7 +116,7 @@ pub fn create_preset(preset: &LegionPreset) -> Result<(), String> { /// Update an existing preset (id must already exist). pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { - let path = preset_path(&preset.id); + let path = preset_path(&preset.id)?; if !path.is_file() { return Err(format!("Legion preset '{}' not found", preset.id)); } @@ -109,7 +127,7 @@ pub fn update_preset(preset: &LegionPreset) -> Result<(), String> { /// Delete a preset by id. pub fn delete_preset(id: &str) -> Result<(), String> { - let path = preset_path(id); + let path = preset_path(id)?; if !path.is_file() { return Err(format!("Legion preset '{id}' not found")); } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 52eb0ef93e..8176a7b5aa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -1,6 +1,6 @@ //! LegionControl tool — load and instantiate legion templates. //! -//! Reads `.bitfun/config/legions/.json`, topologically sorts nodes by +//! Reads `/legions/.json`, topologically sorts nodes by //! edges, creates each node via `SessionControl` path, and returns the //! created session list. @@ -8,11 +8,13 @@ use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler} use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; +use crate::infrastructure::get_path_manager_arc; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use bitfun_runtime_ports::{ - AgentDialogTurnRequest, AgentSessionCreateRequest, DialogSubmissionPolicy, DialogTriggerSource, + AgentDialogTurnRequest, AgentSessionCreateRequest, AgentSessionDeleteRequest, + DialogSubmissionPolicy, DialogTriggerSource, }; use serde::Deserialize; use serde_json::{json, Value}; @@ -69,8 +71,9 @@ impl LegionControlTool { } fn config_dir(&self) -> PathBuf { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - home.join(".bitfun").join("config").join("legions") + get_path_manager_arc() + .user_config_dir() + .join("legions") } fn legion_path(&self, legion_id: &str) -> PathBuf { @@ -88,7 +91,7 @@ impl Tool for LegionControlTool { Ok(concat!( "Load and instantiate a legion template.\n\n", "Actions:\n", - "- load: Read a legion template from `.bitfun/config/legions/.json`, ", + "- load: Read a legion template from the user config directory, ", "topologically sort nodes by edges, create each node as a new agent session ", "via SessionControl, and return the created session list.\n", "- list: List available legion templates.\n\n", @@ -273,8 +276,9 @@ impl LegionControlTool { let node_map: HashMap<&str, &LegionNode> = template.nodes.iter().map(|n| (n.id.as_str(), n)).collect(); - // Create sessions in topological order + // Create sessions in topological order with rollback on failure let mut created_sessions: Vec = Vec::new(); + let mut created_session_ids: Vec<(String, String)> = Vec::new(); // (session_id, workspace) let mut session_lines: Vec = vec![format!( "## Legion: {} ({})", template.name, template.id @@ -284,7 +288,8 @@ impl LegionControlTool { // Batch create: group independent nodes per topological layer let layers = build_topological_layers(&sorted_ids, &template.edges); - for (layer_idx, layer) in layers.iter().enumerate() { + let mut creation_result: BitFunResult<()> = Ok(()); + 'creation: for (layer_idx, layer) in layers.iter().enumerate() { if layer.len() > 1 { session_lines.push(format!( "**Layer {} ({} parallel nodes):**", @@ -305,7 +310,7 @@ impl LegionControlTool { format!("{}-{}", template.name, node.role) }; - let session = runtime + let session = match runtime .create_session(AgentSessionCreateRequest { session_name, agent_type: node.agent.clone(), @@ -330,9 +335,18 @@ impl LegionControlTool { }, }) .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; + { + Ok(session) => session, + Err(error) => { + creation_result = Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(error), + )); + break 'creation; + } + }; + + created_session_ids + .push((session.session_id.clone(), workspace.clone())); let entry = json!({ "node_id": node.id, @@ -354,6 +368,38 @@ impl LegionControlTool { } } + // Rollback on failure: delete all successfully created sessions + if creation_result.is_err() { + let error_msg = creation_result.unwrap_err(); + let mut delete_errors: Vec = Vec::new(); + for (sid, ws) in &created_session_ids { + if let Err(e) = runtime + .delete_session(AgentSessionDeleteRequest { + workspace_path: ws.clone(), + session_id: sid.clone(), + remote_connection_id: None, + remote_ssh_host: None, + }) + .await + { + delete_errors.push(format!(" {}: {}", sid, e)); + } + } + let mut msg = format!( + "Failed to create all legion sessions. {} Created {} session(s) which were cleaned up.", + error_msg, + created_session_ids.len() + ); + if !delete_errors.is_empty() { + msg.push_str(&format!( + "\nNote: {} session(s) could not be cleaned up:\n{}", + delete_errors.len(), + delete_errors.join("\n") + )); + } + return Err(BitFunError::tool(msg)); + } + // Append edge structure if !template.edges.is_empty() { session_lines.push(String::from("### Edges")); diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index b69cf9adf5..0671052ea2 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -23,6 +23,18 @@ struct StaleToolState { consecutive_count: u32, } +/// Remove the stale-tracking entry for a given session. +/// +/// Callers (e.g. session lifecycle hooks) should invoke this when a session +/// is completed, deleted, or cancelled so that the global tracker does not +/// grow without bound. +#[allow(dead_code)] +pub(crate) fn remove_stale_tracker_for_session(session_id: &str) { + if let Ok(mut tracker) = STALE_TRACKER.lock() { + tracker.remove(session_id); + } +} + /// Max consecutive same-tool calls before abort. const STALE_STRATEGY_THRESHOLD: u32 = 3; diff --git a/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs index 48dfa0b84b..39ef9554f9 100644 --- a/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/post_call_hook_execution_contracts.rs @@ -1,6 +1,6 @@ use bitfun_agent_runtime::post_call_hooks::{ resolve_deep_review_shared_context_tool_use, run_successful_tool_post_call_hooks, - DeepReviewSharedContextToolUseFacts, SuccessfulToolPostCallHookExecutor, + DeepReviewSharedContextToolUseFacts, HookResult, SuccessfulToolPostCallHookExecutor, }; use serde_json::{json, Value}; use std::collections::HashMap; @@ -21,6 +21,15 @@ impl SuccessfulToolPostCallHookExecutor<&str> for RecordingExecutor { self.calls .push((tool_name.to_string(), input.clone(), (*context).to_string())); } + + fn behavior_guard( + &mut self, + _tool_name: &str, + _input: &Value, + _context: &&str, + ) -> HookResult { + HookResult::Continue + } } #[test] diff --git a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx index a14ef9e928..5e4b32c7c3 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateLegionPage.tsx @@ -1,10 +1,13 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { ArrowLeft, GitBranch, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Button, IconButton } from '@/component-library'; +import { useNotification } from '@/shared/notification-system'; import PATTERNS, { type LegionPatternNode, type LegionPatternEdge, } from '../data/orchestration-patterns'; +import { LegionPresetAPI } from '@/infrastructure/api/service-api/LegionPresetAPI'; import '../AgentsView.scss'; interface CreateLegionPageProps { @@ -12,23 +15,46 @@ interface CreateLegionPageProps { } const CreateLegionPage: React.FC = ({ onBack }) => { + const { t } = useTranslation('scenes/agents'); + const { success: notifySuccess, error: notifyError } = useNotification(); const [selectedPatternId, setSelectedPatternId] = useState(PATTERNS[0]?.id ?? ''); + const [saving, setSaving] = useState(false); - const patternOptions = useMemo(() => PATTERNS, []); - - const selectedPattern = useMemo( - () => patternOptions.find((p) => p.id === selectedPatternId) ?? null, - [patternOptions, selectedPatternId], - ); + const selectedPattern = PATTERNS.find((p) => p.id === selectedPatternId) ?? null; const handleSelectPattern = useCallback((id: string) => { setSelectedPatternId(id); }, []); - const handleSave = useCallback(() => { - // TODO: save legion preset to backend via Tauri command - onBack(); - }, [onBack]); + const handleSave = useCallback(async () => { + if (!selectedPattern || saving) return; + setSaving(true); + try { + await LegionPresetAPI.createPreset({ + id: selectedPattern.id, + name: selectedPattern.name, + description: selectedPattern.description, + nodes: selectedPattern.nodes.map((n) => ({ + id: n.id, + agent: n.agent, + role: n.role, + prompt: n.prompt, + gate: n.gate, + })), + edges: selectedPattern.edges.map((e) => ({ + from: e.from, + to: e.to, + condition: e.condition, + })), + }); + notifySuccess(`Legion preset "${selectedPattern.name}" saved`); + onBack(); + } catch (err) { + notifyError(`Failed to save legion preset: ${err}`); + } finally { + setSaving(false); + } + }, [selectedPattern, saving, onBack, notifySuccess, notifyError]); const renderNodeList = (nodes: LegionPatternNode[]) => (
@@ -39,7 +65,7 @@ const CreateLegionPage: React.FC = ({ onBack }) => { {node.role} {node.agent}
- {node.gate ? GATE : null} + {node.gate ? {t('legionPattern.gate')} : null}
))}
@@ -69,27 +95,28 @@ const CreateLegionPage: React.FC = ({ onBack }) => {

- {selectedPattern ? selectedPattern.name : 'Choose a Pattern'} + {selectedPattern ? selectedPattern.name : t('legionPattern.choosePattern')}

{/* Pattern selector */}
-

Orchestration Patterns

+

{t('legionPattern.orchestrationPatterns')}

- {patternOptions.map((pattern) => ( + {PATTERNS.map((pattern) => (
handleSelectPattern(pattern.id)} role="button" tabIndex={0} + aria-pressed={pattern.id === selectedPatternId} onKeyDown={(e) => e.key === 'Enter' && handleSelectPattern(pattern.id)} data-testid="legion-pattern-option" data-pattern-id={pattern.id} @@ -105,19 +132,19 @@ const CreateLegionPage: React.FC = ({ onBack }) => { <> {/* Summary */}
-

Overview

+

{t('legionPattern.overview')}

{selectedPattern.description}

- Complexity: L{selectedPattern.complexityLevel} - {selectedPattern.nodes.length} nodes - {selectedPattern.edges.length} edges + {t('legionPattern.complexity', { level: selectedPattern.complexityLevel })} + {t('legionPattern.nodesCount', { count: selectedPattern.nodes.length })} + {t('legionPattern.edgesCount', { count: selectedPattern.edges.length })}
{/* Nodes */}

- Nodes ({selectedPattern.nodes.length}) + {t('legionPattern.nodes', { count: selectedPattern.nodes.length })}

{renderNodeList(selectedPattern.nodes)}
@@ -125,20 +152,20 @@ const CreateLegionPage: React.FC = ({ onBack }) => { {/* Edges */}

- Edges ({selectedPattern.edges.length}) + {t('legionPattern.edges', { count: selectedPattern.edges.length })}

{selectedPattern.edges.length > 0 ? renderEdgeList(selectedPattern.edges, selectedPattern.nodes) - :

No edges (self-contained agent)

} + :

{t('legionPattern.noEdges')}

}
{/* Actions */}
-
diff --git a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx index 498f8effb5..3b8c53d9c8 100644 --- a/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/LegionCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { GitBranch, Users, Network } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Badge } from '@/component-library'; import type { LegionPattern } from '../data/orchestration-patterns'; import './LegionCard.scss'; @@ -10,24 +11,20 @@ interface LegionCardProps { onOpenDetails: (pattern: LegionPattern) => void; } -const COMPLEXITY_LABELS: Record = { - 1: 'L1', - 2: 'L2-L3', - 3: 'L3', - 4: 'L4', - 5: 'L5-L6', - 6: 'L6', - 7: 'L7', -}; - const LegionCard: React.FC = ({ pattern, index = 0, onOpenDetails, }) => { + const { t } = useTranslation('scenes/agents'); const gateNodes = pattern.nodes.filter((n) => n.gate).length; const openDetails = () => onOpenDetails(pattern); + const complexityLabel = + t(`legionPattern.complexityLabel.l${pattern.complexityLevel}`, { + defaultValue: `L${pattern.complexityLevel}`, + }); + return (
= ({ {pattern.name}
- {COMPLEXITY_LABELS[pattern.complexityLevel] ?? `L${pattern.complexityLevel}`} + {complexityLabel}
@@ -66,15 +63,15 @@ const LegionCard: React.FC = ({
- {pattern.nodes.length} nodes + {t('legionPattern.nodesCount', { count: pattern.nodes.length })} - {pattern.edges.length} edges + {t('legionPattern.edgesCount', { count: pattern.edges.length })} {gateNodes > 0 ? ( - {gateNodes} gate + {gateNodes} {t('legionPattern.meta.gate')} ) : null}
diff --git a/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts new file mode 100644 index 0000000000..dff346d9cd --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/LegionPresetAPI.ts @@ -0,0 +1,45 @@ +import { api } from './ApiClient'; + +export interface LegionPresetNode { + id: string; + agent: string; + role: string; + prompt: string; + gate?: boolean; +} + +export interface LegionPresetEdge { + from: string; + to: string; + condition?: string; +} + +export interface LegionPreset { + id: string; + name: string; + description: string; + nodes: LegionPresetNode[]; + edges: LegionPresetEdge[]; +} + +export const LegionPresetAPI = { + async listPresets(): Promise { + return api.invoke('list_legion_presets'); + }, + + async getPreset(id: string): Promise { + return api.invoke('get_legion_preset', { id }); + }, + + async createPreset(preset: LegionPreset): Promise { + return api.invoke('create_legion_preset', { preset }); + }, + + async updatePreset(preset: LegionPreset): Promise { + return api.invoke('update_legion_preset', { preset }); + }, + + async deletePreset(id: string): Promise { + return api.invoke('delete_legion_preset', { id }); + }, +}; diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index 0ac4f27bed..d252f71f27 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -282,5 +282,33 @@ "Debug": "Debug mode: systematically diagnose and fix errors in code", "Claw": "Claw mode: extract and integrate information from external sources", "Team": "Team mode: coordinate multiple agents to collaboratively complete complex tasks" + }, + "legionPattern": { + "back": "Back", + "choosePattern": "Choose a Pattern", + "orchestrationPatterns": "Orchestration Patterns", + "overview": "Overview", + "complexity": "Complexity: L{{level}}", + "nodesCount": "{{count}} nodes", + "edgesCount": "{{count}} edges", + "noEdges": "No edges (self-contained agent)", + "usePattern": "Use Pattern", + "gate": "GATE", + "nodes": "Nodes ({{count}})", + "edges": "Edges ({{count}})", + "meta": { + "nodes": "nodes", + "edges": "edges", + "gate": "gate" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 9c840120f7..1a0d2620f5 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -282,5 +282,33 @@ "Debug": "调试模式:系统性地诊断和修复代码中的错误", "Claw": "抓取模式:从外部源提取和整合信息", "Team": "团队模式:协调多个智能体协同完成复杂任务" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "选择模式", + "orchestrationPatterns": "编排模式", + "overview": "概览", + "complexity": "复杂度:L{{level}}", + "nodesCount": "{{count}} 个节点", + "edgesCount": "{{count}} 条边", + "noEdges": "无边关系(独立智能体)", + "usePattern": "使用此模式", + "gate": "关卡", + "nodes": "节点({{count}})", + "edges": "边({{count}})", + "meta": { + "nodes": "节点", + "edges": "边", + "gate": "关卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index 818ea26a06..e3b9a3b193 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -282,5 +282,33 @@ "Debug": "除錯模式:系統性地診斷和修復程式碼中的錯誤", "Claw": "抓取模式:從外部來源提取和整合資訊", "Team": "團隊模式:協調多個智慧體協同完成複雜任務" + }, + "legionPattern": { + "back": "返回", + "choosePattern": "選擇模式", + "orchestrationPatterns": "編排模式", + "overview": "概覽", + "complexity": "複雜度:L{{level}}", + "nodesCount": "{{count}} 個節點", + "edgesCount": "{{count}} 條邊", + "noEdges": "無邊關係(獨立智慧體)", + "usePattern": "使用此模式", + "gate": "關卡", + "nodes": "節點({{count}})", + "edges": "邊({{count}})", + "meta": { + "nodes": "節點", + "edges": "邊", + "gate": "關卡" + }, + "complexityLabel": { + "l1": "L1", + "l2l3": "L2-L3", + "l3": "L3", + "l4": "L4", + "l5l6": "L5-L6", + "l6": "L6", + "l7": "L7" + } } } From d5497813bf9174ed2297b4a969812d6397f866ea Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Thu, 16 Jul 2026 15:24:35 +0800 Subject: [PATCH 35/48] fix: resolve GetToolSpec duplicate + update registry migration guard tests - framework.rs: add dedup check for GetToolSpec in expanded_tool_names to prevent 'Tool names must be unique' API error - common.rs / message_converter.rs: add dedup in convert_tools_flat and convert_tools for defensive deduplication - catalog.rs / tool_contracts.rs: update test expectations for dedup - registry.rs: add LegionControl to builtin tool manifest test; remove SessionControl/Message/History from collapsed manifest test --- .../src/providers/openai/common.rs | 23 ++++++++++++++++++- .../src/providers/openai/message_converter.rs | 22 +++++++++++++++++- .../core/src/agentic/tools/registry.rs | 4 +--- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs index 69f696d987..53162e7dcb 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/common.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/common.rs @@ -376,7 +376,28 @@ pub(crate) fn convert_tools_flat( tools: Option>, ) -> Option> { tools.map(|defs| { - defs.into_iter() + let mut seen = std::collections::HashSet::new(); + let mut dupes = Vec::new(); + let filtered: Vec = defs + .into_iter() + .filter(|tool| { + if !seen.insert(tool.name.clone()) { + dupes.push(tool.name.clone()); + false + } else { + true + } + }) + .collect(); + if !dupes.is_empty() { + log::error!( + target: "ai::openai_stream_request", + "DUPLICATE TOOL NAMES detected and deduplicated: {:?}", + dupes + ); + } + filtered + .into_iter() .map(|tool| { serde_json::json!({ "type": "function", diff --git a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs index 3ec33276ea..11348c919b 100644 --- a/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs +++ b/src/crates/adapters/ai-adapters/src/providers/openai/message_converter.rs @@ -406,7 +406,27 @@ impl OpenAIMessageConverter { pub fn convert_tools(tools: Option>) -> Option> { tools.map(|tool_defs| { - tool_defs + let mut seen = std::collections::HashSet::new(); + let mut dupes = Vec::new(); + let filtered: Vec = tool_defs + .into_iter() + .filter(|tool| { + if !seen.insert(tool.name.clone()) { + dupes.push(tool.name.clone()); + false + } else { + true + } + }) + .collect(); + if !dupes.is_empty() { + log::error!( + target: "ai::openai_stream_request", + "DUPLICATE TOOL NAMES detected and deduplicated: {:?}", + dupes + ); + } + filtered .into_iter() .map(|tool| { json!({ diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index 8cbab904ab..19918a0513 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -486,6 +486,7 @@ mod tests { "SessionMessage", "SessionHistory", "Cron", + "LegionControl", "WebSearch", "WebFetch", "ListMCPResources", @@ -658,9 +659,6 @@ mod tests { vec![ "CreatePlan", "GetFileDiff", - "SessionControl", - "SessionMessage", - "SessionHistory", "Cron", "WebSearch", "WebFetch", From 09285dbf2b05b451914d34639c7870185bcb69a5 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Fri, 17 Jul 2026 17:46:39 +0800 Subject: [PATCH 36/48] fix: ToolExposure::Expanded -> Direct after rebase to main --- .../src/agentic/tools/implementations/legion_control_tool.rs | 2 +- .../src/agentic/tools/implementations/session_control_tool.rs | 2 +- .../src/agentic/tools/implementations/session_history_tool.rs | 2 +- .../src/agentic/tools/implementations/session_message_tool.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs index 8176a7b5aa..0515606be3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/legion_control_tool.rs @@ -109,7 +109,7 @@ impl Tool for LegionControlTool { } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index ccbd32a8ea..54fe5d9b46 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -276,7 +276,7 @@ Arguments: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index af57e28495..f629de9302 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -106,7 +106,7 @@ Examples: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index 7dd6f9993c..e6d65c67b5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -287,7 +287,7 @@ Allowed agent types when creating a session: } fn default_exposure(&self) -> ToolExposure { - ToolExposure::Expanded + ToolExposure::Direct } fn input_schema(&self) -> Value { From 54c4ea038088bf8d64c5094a55d5baf4832cbaa0 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Fri, 17 Jul 2026 17:50:29 +0800 Subject: [PATCH 37/48] fix: remove redundant unsafe block in register_message_handler --- .../adapters/webdriver/src/platform/evaluator/windows.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs index 59ebd9c36d..6626ad1835 100644 --- a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs @@ -170,7 +170,7 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand } } -unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { unsafe { +unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { let handler: ICoreWebView2WebMessageReceivedEventHandler = WebMessageReceivedHandler.into(); // SAFETY: `EventRegistrationToken` is an FFI value initialized by WebView2, // and both COM interface references remain valid for the duration of the call. @@ -183,7 +183,7 @@ unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDri std::mem::forget(handler); Ok(()) -}} +} fn parse_message_payload(message: &str) -> Option { if let Ok(inner) = serde_json::from_str::(message) { From 6222c57e96a85ab480550b82db56d1753a64a4c9 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Fri, 17 Jul 2026 18:09:28 +0800 Subject: [PATCH 38/48] fix: remove redundant unsafe blocks in windows_capture and windows_wgc_capture --- src/apps/desktop/src/computer_use/windows_capture.rs | 8 ++++---- .../desktop/src/computer_use/windows_wgc_capture.rs | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/apps/desktop/src/computer_use/windows_capture.rs b/src/apps/desktop/src/computer_use/windows_capture.rs index 1d0832b2ac..b2715086e7 100644 --- a/src/apps/desktop/src/computer_use/windows_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_capture.rs @@ -200,7 +200,7 @@ fn screenshot_window_via_wgc(hwnd: HWND) -> BitFunResult<(Vec, u32, u32)> { /// Works for UWP / DirectComposition surfaces that `PrintWindow` can't reach, /// as long as the window is on-screen and not occluded. Returns /// `(bgra_pixels, width, height)`. -unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { unsafe { +unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { let mut rect = RECT::default(); // SAFETY: `rect` is a valid out-parameter and a stale/invalid HWND is // reported by the Win32 API. @@ -280,7 +280,7 @@ unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32 )); } Ok((pixels, w, h)) -}} +} /// A captured window bitmap plus the screen-space geometry it maps to. /// @@ -317,7 +317,7 @@ pub(super) fn screenshot_window_capture(hwnd: HWND) -> BitFunResult BitFunResult { unsafe { +unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult { if hwnd.is_invalid() { return Err(BitFunError::service( "screenshot_window_bytes: invalid HWND", @@ -494,7 +494,7 @@ unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult BitFunResult<(Vec, u32, u32 } } -unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { unsafe { +unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { let mut device: Option = None; let mut context: Option = None; let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; @@ -143,9 +143,9 @@ unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceConte BitFunError::service("D3D11CreateDevice returned null context".to_string()) })?; Ok((device, context)) -}} +} -unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { unsafe { +unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { let dxgi_device: IDXGIDevice = d3d_device .cast() .map_err(|e| BitFunError::service(format!("IDXGIDevice cast: {e}")))?; @@ -156,13 +156,13 @@ unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult BitFunResult<(Vec, u32, u32)> { unsafe { +) -> BitFunResult<(Vec, u32, u32)> { let surface = frame .Surface() .map_err(|e| BitFunError::service(format!("WGC frame Surface: {e}")))?; @@ -227,6 +227,6 @@ unsafe fn copy_frame_to_bgra( unsafe { d3d_context.Unmap(&staging, 0) }; Ok((pixels, width, height)) -}} +} const D3D11_SDK_VERSION: u32 = 7; From e73da5394aa09cfb921309489418a172393d1631 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Fri, 17 Jul 2026 19:00:22 +0800 Subject: [PATCH 39/48] fix: remove unnecessary mut in browser_api release build --- src/apps/desktop/src/api/browser_api.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/apps/desktop/src/api/browser_api.rs b/src/apps/desktop/src/api/browser_api.rs index dc8a520316..f8d9205884 100644 --- a/src/apps/desktop/src/api/browser_api.rs +++ b/src/apps/desktop/src/api/browser_api.rs @@ -142,16 +142,14 @@ pub async fn browser_webview_create( let window = app .get_window("main") .ok_or_else(|| "main window not found".to_string())?; - let mut builder = + let builder = tauri::webview::WebviewBuilder::new(request.label, tauri::WebviewUrl::External(url)) .initialization_script(video_decoder_compatibility_script()) .transparent(false) .background_color(tauri::window::Color(0, 0, 0, 255)); #[cfg(any(debug_assertions, feature = "devtools"))] - { - builder = builder.devtools(true); - } + let builder = builder.devtools(true); window .add_child( From fdf13757dcbb63216fd7aa9c6318736489c861a2 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 05:54:09 +0800 Subject: [PATCH 40/48] feat(hooks): add Stop hook with B01/C01 dual-bee round-level monitoring - RuntimeHookKind::Stop fires after each dialog round completes - StopHookContext carries round-level data (tool calls, file reads/edits) - StopHookExecutor trait with context_guard (B01) + behavior_guard (C01) - run_stop_hooks_for_round() wired into execution_engine round loop - C01 detects systemic violations: all-edits-unread, all-tools-failed --- combine-hooks.ps1 | 44 + docs/plans/stop-hook-dual-bee-monitoring.md | 219 + explore-cc-haha.ps1 | 49 + find-cc-haha.ps1 | 10 + find-hooks-v2.ps1 | 40 + hook-dump/COMBINED.txt | 4684 +++++++++++++++ hook-dump/src_commands_hooks_hooks.tsx | 16 + hook-dump/src_costHook.ts | 25 + .../src_hooks_useDeferredHookMessages.ts | 49 + hook-dump/src_query_stopHooks.test.ts | 52 + hook-dump/src_query_stopHooks.ts | 594 ++ hook-dump/src_schemas_hooks.ts | 225 + hook-dump/src_services_tools_toolHooks.ts | 655 +++ hook-dump/src_types_hooks.ts | 293 + hook-dump/src_utils_hooks.ts | 5044 +++++++++++++++++ .../src_utils_hooks_AsyncHookRegistry.ts | 312 + .../src_utils_hooks_apiQueryHookHelper.ts | 144 + hook-dump/src_utils_hooks_execAgentHook.ts | 342 ++ hook-dump/src_utils_hooks_execHttpHook.ts | 245 + hook-dump/src_utils_hooks_execPromptHook.ts | 257 + .../src_utils_hooks_fileChangedWatcher.ts | 194 + hook-dump/src_utils_hooks_hookEvents.ts | 195 + hook-dump/src_utils_hooks_hookHelpers.ts | 86 + .../src_utils_hooks_hooksConfigManager.ts | 403 ++ .../src_utils_hooks_hooksConfigSnapshot.ts | 136 + hook-dump/src_utils_hooks_hooksSettings.ts | 274 + .../src_utils_hooks_postSamplingHooks.ts | 73 + ...rc_utils_hooks_registerFrontmatterHooks.ts | 70 + .../src_utils_hooks_registerSkillHooks.ts | 67 + hook-dump/src_utils_hooks_sessionHooks.ts | 450 ++ read-hooks.ps1 | 50 + .../src/agentic/execution/execution_engine.rs | 95 + .../implementations/session_control_tool.rs | 2 +- .../implementations/session_message_tool.rs | 2 +- .../core/src/agentic/tools/post_call_hooks.rs | 313 +- .../agent-runtime/src/post_call_hooks.rs | 120 +- 36 files changed, 15780 insertions(+), 49 deletions(-) create mode 100644 combine-hooks.ps1 create mode 100644 docs/plans/stop-hook-dual-bee-monitoring.md create mode 100644 explore-cc-haha.ps1 create mode 100644 find-cc-haha.ps1 create mode 100644 find-hooks-v2.ps1 create mode 100644 hook-dump/COMBINED.txt create mode 100644 hook-dump/src_commands_hooks_hooks.tsx create mode 100644 hook-dump/src_costHook.ts create mode 100644 hook-dump/src_hooks_useDeferredHookMessages.ts create mode 100644 hook-dump/src_query_stopHooks.test.ts create mode 100644 hook-dump/src_query_stopHooks.ts create mode 100644 hook-dump/src_schemas_hooks.ts create mode 100644 hook-dump/src_services_tools_toolHooks.ts create mode 100644 hook-dump/src_types_hooks.ts create mode 100644 hook-dump/src_utils_hooks.ts create mode 100644 hook-dump/src_utils_hooks_AsyncHookRegistry.ts create mode 100644 hook-dump/src_utils_hooks_apiQueryHookHelper.ts create mode 100644 hook-dump/src_utils_hooks_execAgentHook.ts create mode 100644 hook-dump/src_utils_hooks_execHttpHook.ts create mode 100644 hook-dump/src_utils_hooks_execPromptHook.ts create mode 100644 hook-dump/src_utils_hooks_fileChangedWatcher.ts create mode 100644 hook-dump/src_utils_hooks_hookEvents.ts create mode 100644 hook-dump/src_utils_hooks_hookHelpers.ts create mode 100644 hook-dump/src_utils_hooks_hooksConfigManager.ts create mode 100644 hook-dump/src_utils_hooks_hooksConfigSnapshot.ts create mode 100644 hook-dump/src_utils_hooks_hooksSettings.ts create mode 100644 hook-dump/src_utils_hooks_postSamplingHooks.ts create mode 100644 hook-dump/src_utils_hooks_registerFrontmatterHooks.ts create mode 100644 hook-dump/src_utils_hooks_registerSkillHooks.ts create mode 100644 hook-dump/src_utils_hooks_sessionHooks.ts create mode 100644 read-hooks.ps1 diff --git a/combine-hooks.ps1 b/combine-hooks.ps1 new file mode 100644 index 0000000000..975fa617fc --- /dev/null +++ b/combine-hooks.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Continue' +$base = "E:\Yuanban\BitFun-src\hook-dump" + +# Files in priority order +$files = @( + "src_schemas_hooks.ts", + "src_utils_hooks_hooksConfigManager.ts", + "src_utils_hooks_sessionHooks.ts", + "src_utils_hooks_execPromptHook.ts", + "src_utils_hooks_execAgentHook.ts", + "src_utils_hooks_execHttpHook.ts", + "src_utils_hooks_AsyncHookRegistry.ts", + "src_utils_hooks_postSamplingHooks.ts", + "src_services_tools_toolHooks.ts", + "src_utils_hooks_hooksConfigSnapshot.ts", + "src_commands_hooks_hooks.tsx", + "src_costHook.ts", + "src_hooks_useDeferredHookMessages.ts", + "src_query_stopHooks.ts", + "src_utils_hooks_hooksSettings.ts", + "src_utils_hooks_registerFrontmatterHooks.ts", + "src_utils_hooks_registerSkillHooks.ts", + "src_utils_hooks_apiQueryHookHelper.ts", + "src_utils_hooks_fileChangedWatcher.ts", + "src_query_stopHooks.test.ts" +) + +$out = "E:\Yuanban\BitFun-src\hook-dump\COMBINED.txt" +"" | Out-File $out -Encoding utf8 + +foreach ($f in $files) { + $full = Join-Path $base $f + if (Test-Path $full) { + "`n`n========================================" | Out-File $out -Append -Encoding utf8 + "FILE: $f" | Out-File $out -Append -Encoding utf8 + "========================================" | Out-File $out -Append -Encoding utf8 + Get-Content $full | Out-File $out -Append -Encoding utf8 + } else { + "`nMISSING: $f" | Out-File $out -Append -Encoding utf8 + } +} + +Write-Host "Combined to $out" +Write-Host "Size: $((Get-Item $out).Length) bytes" diff --git a/docs/plans/stop-hook-dual-bee-monitoring.md b/docs/plans/stop-hook-dual-bee-monitoring.md new file mode 100644 index 0000000000..a87b6a2682 --- /dev/null +++ b/docs/plans/stop-hook-dual-bee-monitoring.md @@ -0,0 +1,219 @@ +# Stop Hook + B01/C01 双蜂监控实现计划 + +## Background + +### 当前状态 + +BitFun 只有 **post-tool-call** 级别的 hook: + +- `SuccessfulToolPostCall`:记录 DeepReview 共享上下文 +- `BehaviorGuard`:stale strategy 检测、Read-before-Edit、LionHeart 路径保护、PowerShell+中文检测 + +所有 hook 在 `call_with_tool_runtime_hooks()`([tool_context_runtime.rs:190](src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs))中触发,仅在单个工具调用成功后执行。 + +### 缺失 + +- **无 turn/session 级别的生命周期 hook**:Stop、SessionStart、SessionEnd +- **无法在回合结束后进行整体审查**:只能逐工具检查,看不到"本轮整体有没有跑偏" +- **B01 提示蜂和 C01 审查蜂只是 SKILL.md 中的提示词级模拟**,不是真正的代码级拦截 + +### 参考设计 + +1. **cc-haha `/goal`**([goalState.ts](https://github.com/anthropics/claude-code/blob/main/src/goals/goalState.ts)):`addSessionHook(context, threadId, 'Stop', '', hook)` — PromptHook 挂载 Stop 事件,每轮回合结束后用小 LLM 评估,返回 `{ok: true/false, reason}` +2. **用户 V10 蔷薇 Harness**:Plan→Do→Check→Act 监督链,"任何 agent 不能单独做决定,每个输出至少被另一个 agent 审查过" +3. **用户 V12 梦蝶军团**:Odysseus 军师作为被动观察者,悄悄话通道通信,用户对监督 agent 无感知 + +### 核心洞察 + +B01(提示蜂)和 C01(审查蜂)本质是**同一个 Stop hook 机制**的两个 handler,区别仅在于评估 prompt: + +``` +Stop 事件触发(每回合 agent 产出后) + │ + ├─→ B01 handler: "本轮执行需要什么上下文?缺了吗?" + │ └─ 缺 → 注入补充知识 → 下一轮 + │ + └─→ C01 handler: "本轮违反了哪条铁则?" + └─ 违 → Abort + fix_instruction → 下一轮修正 +``` + +双蜂挂同一个 Stop 事件,形成一个完整的"军师"模式——沉默观察、精准出手。 + +## Implementation Approach + +### 架构变更 + +``` +execution_engine.rs: execute_dialog_turn_impl() + │ + ├─ [每 round 结束] + │ │ + │ ├─ (现有) 检查 continue_loop / max_rounds + │ │ + │ └─ (新增) run_stop_hooks(tool_name="__turn_end__", round_result, context) + │ │ + │ ├─ RuntimeHookKind::Stop → B01 提示蜂 handler + │ │ └─ 评估上下文完整性 → 注入补充提示 + │ │ + │ └─ RuntimeHookKind::Stop → C01 审查蜂 handler + │ └─ 评估铁则违规 → Abort / Continue + │ + └─ [loop 继续或终止] +``` + +### 三层改动 + +| 层 | 文件 | 改动 | +|---|---|---| +| **契约层** | `execution/agent-runtime/src/post_call_hooks.rs` | 新增 `RuntimeHookKind::Stop`;扩展 `RuntimeHookRegistry` 支持 Stop hook;新增 `StopHookExecutor` trait | +| **引擎层** | `assembly/core/src/agentic/execution/execution_engine.rs` | 在 round 结束后调用 `run_stop_hooks()` | +| **实现层** | `assembly/core/src/agentic/tools/post_call_hooks.rs` | 实现 B01 context-check handler;实现 C01 iron-rule-check handler | + +## File Change List + +### 1. `src/crates/execution/agent-runtime/src/post_call_hooks.rs` — 契约层 + +**新增 `RuntimeHookKind::Stop`**: +```rust +#[non_exhaustive] +pub enum RuntimeHookKind { + SuccessfulToolPostCall, + DeepReviewSharedContextToolUse, + BehaviorGuard, + Stop, // ← 新增 +} +``` + +**新增 `StopHookContext`**:携带回合级别的审查信息 +```rust +pub struct StopHookContext { + pub session_id: String, + pub turn_id: String, + pub round_number: u32, + pub tool_calls_in_round: Vec, // 本轮所有工具调用 + pub assistant_message_summary: String, // agent 本轮输出摘要 + pub file_reads_in_round: Vec, // 本轮读取的文件 + pub file_edits_in_round: Vec, // 本轮编辑的文件 +} +``` + +**新增 `StopHookExecutor` trait**: +```rust +pub trait StopHookExecutor { + fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult; // B01 + fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult; // C01 +} +``` + +**新增 `run_stop_hooks()` 函数**:遍历注册的 Stop handler,收集 `HookResult`。 + +### 2. `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` — 引擎层 + +在 `execute_dialog_turn_impl()` 的 round loop 中,每轮结束后插入: + +```rust +// 现有:压缩上下文、检查 max_rounds、决定是否 continue +// ... + +// 新增:Stop hook 注入点 +if let Some(stop_hook) = &self.stop_hook_executor { + let ctx = StopHookContext { + session_id: ..., + turn_id: ..., + round_number: current_round, + tool_calls_in_round: collect_tool_calls_this_round(), + assistant_message_summary: summarize_assistant_output(), + file_reads_in_round: session_file_reads(), + file_edits_in_round: session_file_edits(), + }; + let result = run_stop_hooks(&ctx, stop_hook); + match result { + HookResult::Continue => { /* 正常继续 */ } + HookResult::Abort { reason, fix_instruction, .. } => { + // 注入拦截消息到下一轮 + inject_guard_message(reason, fix_instruction); + } + } +} +``` + +### 3. `src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs` — 实现层 + +**B01 提示蜂 handler(context_guard)**: +```rust +fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult { + // 1. 检查本轮是否读取了要编辑的文件 → 已在 per-tool FILE_READ_TRACKER 中检查 + // 2. 检查是否有现成工具可用但被忽略 → 提示 + // 3. 检查上下文是否足够(被编辑的文件是否读了足够的行数) + // 4. 当前为 Should-level,不 Abort,只注入提示 + HookResult::Continue // 暂不拦截,仅记录 +} +``` + +**C01 审查蜂 handler(behavior_guard)**: +```rust +fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult { + // 复用 per-tool 级别的 FILE_READ_TRACKER + STALE_TRACKER 数据 + // 从回合级别做综合判断: + + // 1. Read-before-Edit 聚合检查:本轮所有 Edit 的文件,是否都在本轮或之前 Read 过 + for edit in &ctx.file_edits_in_round { + if !ctx.file_reads_in_round.iter().any(|r| normalize_path(r) == normalize_path(edit)) { + // 已在 per-tool 级别拦截,此处做回合级聚合报告 + } + } + + // 2. 重复工具调用检测:同一工具连续 N 轮都出现 → 可能陷入循环 + // (比 per-tool 的 3 次连续调用更宏观) + + // 3. 全局扫描检查:agent 是否在钻牛角尖(心智模型1) + // 例如:连续 3 轮都只调用同一个工具类型 + + HookResult::Continue // 本期先建立框架,具体规则后续迭代 +} +``` + +### 4. `src/crates/execution/agent-runtime/src/runtime.rs` — 注册 + +在 `AgentRuntime` 构建时注册 Stop hook executor: +```rust +runtime_hook_registry + .register(RuntimeHookPlan::new("stop-bee-guard", RuntimeHookKind::Stop) + .with_order(200) + .with_timeout_millis(5_000)); +``` + +## 实施步骤 + +### Phase 1:契约层(最小改动,先跑通链路) + +1. `post_call_hooks.rs`(execution 层):加 `RuntimeHookKind::Stop`、`StopHookContext`、`StopHookExecutor` trait、`run_stop_hooks()` +2. 跑 `cargo check` 确认编译 + +### Phase 2:引擎层(挂载注入点) + +3. `execution_engine.rs`:在 round loop 结束后构造 `StopHookContext`、调用 `run_stop_hooks()` +4. 跑 `cargo check` + `cargo test -p bitfun-agent-runtime --test post_call_hook_execution_contracts` + +### Phase 3:实现层(双蜂 handler) + +5. `post_call_hooks.rs`(assembly 层):实现 B01 context_guard + C01 behavior_guard +6. 跑完整 `cargo test -p bitfun-core` + +### Phase 4:集成验证 + +7. 端到端手动验证:开启 Team 模式,确认 Stop hook 在每轮后触发 +8. 跑标准验证套件:`cargo check --workspace` + `pnpm run type-check:web` + +## 与已有代码的关系 + +- **不修改** `tool_pipeline.rs`(per-tool 级别 hook 保持不变) +- **不修改** 现有的 `BehaviorGuard`(per-tool 级别继续生效) +- **新增的 Stop hook 是回合级别的补充**,不是替代 +- Per-tool hook 和 Stop hook 形成两层防御:逐工具检查 + 回合结束后全局审查 + +## 后续扩展方向(本期不做) + +- `SessionStart` / `SessionEnd` hook:session 生命周期级审查 +- PromptHook 类型:用小 LLM(如 Haiku/DeepSeek-Flash)评估条件,替代纯 Rust 规则 +- 自动挂载 C01 审查蜂 session:Stop hook 检测到严重违规时,自动 spawn 独立审查 session diff --git a/explore-cc-haha.ps1 b/explore-cc-haha.ps1 new file mode 100644 index 0000000000..5fd6841de0 --- /dev/null +++ b/explore-cc-haha.ps1 @@ -0,0 +1,49 @@ +$ErrorActionPreference = 'Continue' + +# 1. Try git pull in various locations +$locations = @( + "E:\Yuanban\cc-haha", + "E:\Yuanban\cc-haha\Claude Code Haha", + "E:\Yuanban\cc-haha-src" +) + +foreach ($loc in $locations) { + Write-Host "=== Checking: $loc ===" + if (Test-Path "$loc\.git") { + Write-Host "Git repo found. Pulling..." + git -C $loc pull --rebase 2>&1 + Write-Host "Git pull done for $loc" + } else { + Write-Host "Not a git repo (no .git folder)" + } + Write-Host "" +} + +# 2. List top-level directory structure +Write-Host "=== Listing E:\Yuanban\cc-haha top-level ===" +Get-ChildItem "E:\Yuanban\cc-haha" -Depth 1 | Select-Object Name, PSIsContainer | Format-Table -AutoSize + +Write-Host "=== Listing E:\Yuanban\cc-haha-src top-level ===" +Get-ChildItem "E:\Yuanban\cc-haha-src" -Depth 1 -ErrorAction SilentlyContinue | Select-Object Name, PSIsContainer | Format-Table -AutoSize + +# 3. Find all hook-related files +Write-Host "=== Searching for hook-related files (name contains 'hook') ===" +Get-ChildItem "E:\Yuanban\cc-haha" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'hook' } | Select-Object FullName | Format-Table -AutoSize + +Write-Host "=== Searching for files containing 'HookResult' or 'Abort' or 'behavior_guard' ===" +$searchDirs = @("E:\Yuanban\cc-haha", "E:\Yuanban\cc-haha-src") +foreach ($dir in $searchDirs) { + if (Test-Path $dir) { + Write-Host "--- Searching in $dir ---" + Get-ChildItem $dir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $_.Extension -match '\.(ts|js|tsx|jsx|rs|py|go|java)$' + } | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content -match 'HookResult|Abort|behavior_guard') { + Write-Host "MATCH: $($_.FullName)" + } + } + } +} + +Write-Host "=== DONE ===" diff --git a/find-cc-haha.ps1 b/find-cc-haha.ps1 new file mode 100644 index 0000000000..8ff904f2e3 --- /dev/null +++ b/find-cc-haha.ps1 @@ -0,0 +1,10 @@ +$ErrorActionPreference = 'Stop' +$drives = @('E:\') +foreach ($drive in $drives) { + Write-Host "Searching in $drive..." + $results = Get-ChildItem -Path $drive -Directory -Depth 2 -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'cc-haha|claude.code|CC-haha' } + foreach ($r in $results) { + Write-Host "FOUND: $($r.FullName)" + } +} +Write-Host "Search complete." diff --git a/find-hooks-v2.ps1 b/find-hooks-v2.ps1 new file mode 100644 index 0000000000..2c2a53fd5f --- /dev/null +++ b/find-hooks-v2.ps1 @@ -0,0 +1,40 @@ +$ErrorActionPreference = 'Continue' +$srcDir = "E:\Yuanban\cc-haha-src" + +# Find files with "hook" in name, excluding node_modules, dist, .git +Write-Host "=== Files with 'hook' in name (source only) ===" +Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $_.Name -match 'hook' -and + $_.FullName -notmatch '\\node_modules\\' -and + $_.FullName -notmatch '\\dist\\' -and + $_.FullName -notmatch '\\.git\\' -and + $_.FullName -notmatch '\\__pycache__\\' +} | Select-Object FullName | Format-Table -AutoSize + +Write-Host "=== Files containing 'HookResult' (source only) ===" +Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $_.Extension -match '\.(ts|js|tsx|jsx)$' -and + $_.FullName -notmatch '\\node_modules\\' -and + $_.FullName -notmatch '\\dist\\' -and + $_.FullName -notmatch '\\.git\\' +} | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content -match 'HookResult') { + Write-Host "HookResult: $($_.FullName)" + } +} + +Write-Host "=== Files containing 'behavior_guard' (source only) ===" +Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { + $_.Extension -match '\.(ts|js|tsx|jsx)$' -and + $_.FullName -notmatch '\\node_modules\\' -and + $_.FullName -notmatch '\\dist\\' -and + $_.FullName -notmatch '\\.git\\' +} | ForEach-Object { + $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue + if ($content -match 'behavior_guard') { + Write-Host "behavior_guard: $($_.FullName)" + } +} + +Write-Host "=== DONE ===" diff --git a/hook-dump/COMBINED.txt b/hook-dump/COMBINED.txt new file mode 100644 index 0000000000..738966ea18 --- /dev/null +++ b/hook-dump/COMBINED.txt @@ -0,0 +1,4684 @@ + + + +======================================== +FILE: src_schemas_hooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\schemas\hooks.ts +LINES: 222 +========== +/** + * Hook Zod schemas extracted to break import cycles. + * + * This file contains hook-related schema definitions that were originally + * in src/utils/settings/types.ts. By extracting them here, we break the + * circular dependency between settings/types.ts and plugins/schemas.ts. + * + * Both files now import from this shared location instead of each other. + */ + +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { z } from 'zod/v4' +import { lazySchema } from '../utils/lazySchema.js' +import { SHELL_TYPES } from '../utils/shell/shellProvider.js' + +// Shared schema for the `if` condition field. +// Uses permission rule syntax (e.g., "Bash(git *)", "Read(*.ts)") to filter hooks +// before spawning. Evaluated against the hook input's tool_name and tool_input. +const IfConditionSchema = lazySchema(() => + z + .string() + .optional() + .describe( + 'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' + + 'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.', + ), +) + +// Internal factory for individual hook schemas (shared between exported +// discriminated union members and the HookCommandSchema factory) +function buildHookSchemas() { + const BashCommandHookSchema = z.object({ + type: z.literal('command').describe('Shell command hook type'), + command: z.string().describe('Shell command to execute'), + if: IfConditionSchema(), + shell: z + .enum(SHELL_TYPES) + .optional() + .describe( + "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.", + ), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific command'), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + async: z + .boolean() + .optional() + .describe('If true, hook runs in background without blocking'), + asyncRewake: z + .boolean() + .optional() + .describe( + 'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.', + ), + }) + + const PromptHookSchema = z.object({ + type: z.literal('prompt').describe('LLM prompt hook type'), + prompt: z + .string() + .describe( + 'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.', + ), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific prompt evaluation'), + // @[MODEL LAUNCH]: Update the example model ID in the .describe() strings below (prompt + agent hooks). + model: z + .string() + .optional() + .describe( + 'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + const HttpHookSchema = z.object({ + type: z.literal('http').describe('HTTP hook type'), + url: z.string().url().describe('URL to POST the hook input JSON to'), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific request'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', + ), + allowedEnvVars: z + .array(z.string()) + .optional() + .describe( + 'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + const AgentHookSchema = z.object({ + type: z.literal('agent').describe('Agentic verifier hook type'), + // DO NOT add .transform() here. This schema is used by parseSettingsFile, + // and updateSettingsForSource round-trips the parsed result through + // JSON.stringify 鈥?a transformed function value is silently dropped, + // deleting the user's prompt from settings.json (gh-24920, CC-79). The + // transform (from #10594) wrapped the string in `(_msgs) => prompt` + // for a programmatic-construction use case in ExitPlanModeV2Tool that + // has since been refactored into VerifyPlanExecutionTool, which no + // longer constructs AgentHook objects at all. + prompt: z + .string() + .describe( + 'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.', + ), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for agent execution (default 60)'), + model: z + .string() + .optional() + .describe( + 'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + return { + BashCommandHookSchema, + PromptHookSchema, + HttpHookSchema, + AgentHookSchema, + } +} + +/** + * Schema for hook command (excludes function hooks - they can't be persisted) + */ +export const HookCommandSchema = lazySchema(() => { + const { + BashCommandHookSchema, + PromptHookSchema, + AgentHookSchema, + HttpHookSchema, + } = buildHookSchemas() + return z.discriminatedUnion('type', [ + BashCommandHookSchema, + PromptHookSchema, + AgentHookSchema, + HttpHookSchema, + ]) +}) + +/** + * Schema for matcher configuration with multiple hooks + */ +export const HookMatcherSchema = lazySchema(() => + z.object({ + matcher: z + .string() + .optional() + .describe('String pattern to match (e.g. tool names like "Write")'), // String (e.g. Write) to match values related to the hook event, e.g. tool names + hooks: z + .array(HookCommandSchema()) + .describe('List of hooks to execute when the matcher matches'), + }), +) + +/** + * Schema for hooks configuration + * The key is the hook event. The value is an array of matcher configurations. + * Uses partialRecord since not all hook events need to be defined. + */ +export const HooksSchema = lazySchema(() => + z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())), +) + +// Inferred types from schemas +export type HookCommand = z.infer> +export type BashCommandHook = Extract +export type PromptHook = Extract +export type AgentHook = Extract +export type HttpHook = Extract +export type HookMatcher = z.infer> +export type HooksSettings = Partial> + + +======================================== +FILE: src_utils_hooks_hooksConfigManager.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigManager.ts +LINES: 400 +========== +import memoize from 'lodash-es/memoize.js' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { getRegisteredHooks } from '../../bootstrap/state.js' +import type { AppState } from '../../state/AppState.js' +import { + getAllHooks, + type IndividualHookConfig, + sortMatchersByPriority, +} from './hooksSettings.js' + +export type MatcherMetadata = { + fieldToMatch: string + values: string[] +} + +export type HookEventMetadata = { + summary: string + description: string + matcherMetadata?: MatcherMetadata +} + +// Hook event metadata configuration. +// Resolver uses sorted-joined string key so that callers passing a fresh +// toolNames array each render (e.g. HooksConfigMenu) hit the cache instead +// of leaking a new entry per call. +export const getHookEventMetadata = memoize( + function (toolNames: string[]): Record { + return { + PreToolUse: { + summary: 'Before tool execution', + description: + 'Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PostToolUse: { + summary: 'After tool execution', + description: + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PostToolUseFailure: { + summary: 'After tool execution fails', + description: + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PermissionDenied: { + summary: 'After auto mode classifier denies a tool call', + description: + 'Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {"hookSpecificOutput":{"hookEventName":"PermissionDenied","retry":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + Notification: { + summary: 'When notifications are sent', + description: + 'Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'notification_type', + values: [ + 'permission_prompt', + 'idle_prompt', + 'auth_success', + 'elicitation_dialog', + 'elicitation_complete', + 'elicitation_response', + ], + }, + }, + UserPromptSubmit: { + summary: 'When the user submits a prompt', + description: + 'Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only', + }, + SessionStart: { + summary: 'When a new session is started', + description: + 'Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'source', + values: ['startup', 'resume', 'clear', 'compact'], + }, + }, + Stop: { + summary: 'Right before Claude concludes its response', + description: + 'Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only', + }, + StopFailure: { + summary: 'When the turn ends due to an API error', + description: + 'Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget 鈥?hook output and exit codes are ignored.', + matcherMetadata: { + fieldToMatch: 'error', + values: [ + 'rate_limit', + 'authentication_failed', + 'billing_error', + 'invalid_request', + 'server_error', + 'max_output_tokens', + 'unknown', + ], + }, + }, + SubagentStart: { + summary: 'When a subagent (Agent tool call) is started', + description: + 'Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'agent_type', + values: [], // Will be populated with available agent types + }, + }, + SubagentStop: { + summary: + 'Right before a subagent (Agent tool call) concludes its response', + description: + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'agent_type', + values: [], // Will be populated with available agent types + }, + }, + PreCompact: { + summary: 'Before conversation compaction', + description: + 'Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['manual', 'auto'], + }, + }, + PostCompact: { + summary: 'After conversation compaction', + description: + 'Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['manual', 'auto'], + }, + }, + SessionEnd: { + summary: 'When a session is ending', + description: + 'Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'reason', + values: ['clear', 'logout', 'prompt_input_exit', 'other'], + }, + }, + PermissionRequest: { + summary: 'When a permission dialog is displayed', + description: + 'Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + Setup: { + summary: 'Repo setup hooks for init and maintenance', + description: + 'Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['init', 'maintenance'], + }, + }, + TeammateIdle: { + summary: 'When a teammate is about to go idle', + description: + 'Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only', + }, + TaskCreated: { + summary: 'When a task is being created', + description: + 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only', + }, + TaskCompleted: { + summary: 'When a task is being marked as completed', + description: + 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only', + }, + Elicitation: { + summary: 'When an MCP server requests user input (elicitation)', + description: + 'Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'mcp_server_name', + values: [], + }, + }, + ElicitationResult: { + summary: 'After a user responds to an MCP elicitation', + description: + 'Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'mcp_server_name', + values: [], + }, + }, + ConfigChange: { + summary: 'When configuration files change during a session', + description: + 'Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'source', + values: [ + 'user_settings', + 'project_settings', + 'local_settings', + 'policy_settings', + 'skills', + ], + }, + }, + InstructionsLoaded: { + summary: 'When an instruction file (CLAUDE.md or rule) is loaded', + description: + 'Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional 鈥?the paths: frontmatter patterns that matched), trigger_file_path (optional 鈥?the file Claude touched that caused the load), and parent_file_path (optional 鈥?the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.', + matcherMetadata: { + fieldToMatch: 'load_reason', + values: [ + 'session_start', + 'nested_traversal', + 'path_glob_match', + 'include', + 'compact', + ], + }, + }, + WorktreeCreate: { + summary: 'Create an isolated worktree for VCS-agnostic isolation', + description: + 'Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed', + }, + WorktreeRemove: { + summary: 'Remove a previously created worktree', + description: + 'Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only', + }, + CwdChanged: { + summary: 'After the working directory changes', + description: + 'Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + }, + FileChanged: { + summary: 'When a watched file changes', + description: + 'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + }, + } + }, + toolNames => toolNames.slice().sort().join(','), +) + +// Group hooks by event and matcher +export function groupHooksByEventAndMatcher( + appState: AppState, + toolNames: string[], +): Record> { + const grouped: Record> = { + PreToolUse: {}, + PostToolUse: {}, + PostToolUseFailure: {}, + PermissionDenied: {}, + Notification: {}, + UserPromptSubmit: {}, + SessionStart: {}, + SessionEnd: {}, + Stop: {}, + StopFailure: {}, + SubagentStart: {}, + SubagentStop: {}, + PreCompact: {}, + PostCompact: {}, + PermissionRequest: {}, + Setup: {}, + TeammateIdle: {}, + TaskCreated: {}, + TaskCompleted: {}, + Elicitation: {}, + ElicitationResult: {}, + ConfigChange: {}, + WorktreeCreate: {}, + WorktreeRemove: {}, + InstructionsLoaded: {}, + CwdChanged: {}, + FileChanged: {}, + } + + const metadata = getHookEventMetadata(toolNames) + + // Include hooks from settings files + getAllHooks(appState).forEach(hook => { + const eventGroup = grouped[hook.event] + if (eventGroup) { + // For events without matchers, use empty string as key + const matcherKey = + metadata[hook.event].matcherMetadata !== undefined + ? hook.matcher || '' + : '' + if (!eventGroup[matcherKey]) { + eventGroup[matcherKey] = [] + } + eventGroup[matcherKey].push(hook) + } + }) + + // Include registered hooks (e.g., plugin hooks) + const registeredHooks = getRegisteredHooks() + if (registeredHooks) { + for (const [event, matchers] of Object.entries(registeredHooks)) { + const hookEvent = event as HookEvent + const eventGroup = grouped[hookEvent] + if (!eventGroup) continue + + for (const matcher of matchers) { + const matcherKey = matcher.matcher || '' + + // Only PluginHookMatcher has pluginRoot; HookCallbackMatcher (internal + // callbacks like attributionHooks, sessionFileAccessHooks) does not. + if ('pluginRoot' in matcher) { + eventGroup[matcherKey] ??= [] + for (const hook of matcher.hooks) { + eventGroup[matcherKey].push({ + event: hookEvent, + config: hook, + matcher: matcher.matcher, + source: 'pluginHook', + pluginName: matcher.pluginId, + }) + } + } else if (process.env.USER_TYPE === 'ant') { + eventGroup[matcherKey] ??= [] + for (const _hook of matcher.hooks) { + eventGroup[matcherKey].push({ + event: hookEvent, + config: { + type: 'command', + command: '[ANT-ONLY] Built-in Hook', + }, + matcher: matcher.matcher, + source: 'builtinHook', + }) + } + } + } + } + } + + return grouped +} + +// Get sorted matchers for a specific event +export function getSortedMatchersForEvent( + hooksByEventAndMatcher: Record< + HookEvent, + Record + >, + event: HookEvent, +): string[] { + const matchers = Object.keys(hooksByEventAndMatcher[event] || {}) + return sortMatchersByPriority(matchers, hooksByEventAndMatcher, event) +} + +// Get hooks for a specific event and matcher +export function getHooksForMatcher( + hooksByEventAndMatcher: Record< + HookEvent, + Record + >, + event: HookEvent, + matcher: string | null, +): IndividualHookConfig[] { + // For events without matchers, hooks are stored with empty string as key + // because the record keys must be strings. + const matcherKey = matcher ?? '' + return hooksByEventAndMatcher[event]?.[matcherKey] ?? [] +} + +// Get metadata for a specific event's matcher +export function getMatcherMetadata( + event: HookEvent, + toolNames: string[], +): MatcherMetadata | undefined { + return getHookEventMetadata(toolNames)[event].matcherMetadata +} + + +======================================== +FILE: src_utils_hooks_sessionHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\sessionHooks.ts +LINES: 447 +========== +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import type { Message } from 'src/types/message.js' +import { logForDebugging } from '../debug.js' +import type { AggregatedHookResult } from '../hooks.js' +import type { HookCommand } from '../settings/types.js' +import { isHookEqual } from './hooksSettings.js' + +type OnHookSuccess = ( + hook: HookCommand | FunctionHook, + result: AggregatedHookResult, +) => void + +/** Function hook callback - returns true if check passes, false to block */ +export type FunctionHookCallback = ( + messages: Message[], + signal?: AbortSignal, +) => boolean | Promise + +/** + * Function hook type with callback embedded. + * Session-scoped only, cannot be persisted to settings.json. + */ +export type FunctionHook = { + type: 'function' + id?: string // Optional unique ID for removal + timeout?: number + callback: FunctionHookCallback + errorMessage: string + statusMessage?: string +} + +type SessionHookMatcher = { + matcher: string + skillRoot?: string + hooks: Array<{ + hook: HookCommand | FunctionHook + onHookSuccess?: OnHookSuccess + }> +} + +export type SessionStore = { + hooks: { + [event in HookEvent]?: SessionHookMatcher[] + } +} + +/** + * Map (not Record) so .set/.delete don't change the container's identity. + * Mutator functions mutate the Map and return prev unchanged, letting + * store.ts's Object.is(next, prev) check short-circuit and skip listener + * notification. Session hooks are ephemeral per-agent runtime callbacks, + * never reactively read (only getAppState() snapshots in the query loop). + * Same pattern as agentControllers on LocalWorkflowTaskState. + * + * This matters under high-concurrency workflows: parallel() with N + * schema-mode agents fires N addFunctionHook calls in one synchronous + * tick. With a Record + spread, each call cost O(N) to copy the growing + * map (O(N虏) total) plus fired all ~30 store listeners. With Map: .set() + * is O(1), return prev means zero listener fires. + */ +export type SessionHooksState = Map + +/** + * Add a command or prompt hook to the session. + * Session hooks are temporary, in-memory only, and cleared when session ends. + */ +export function addSessionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand, + onHookSuccess?: OnHookSuccess, + skillRoot?: string, +): void { + addHookToSession( + setAppState, + sessionId, + event, + matcher, + hook, + onHookSuccess, + skillRoot, + ) +} + +/** + * Add a function hook to the session. + * Function hooks execute TypeScript callbacks in-memory for validation. + * @returns The hook ID (for removal) + */ +export function addFunctionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + callback: FunctionHookCallback, + errorMessage: string, + options?: { + timeout?: number + id?: string + }, +): string { + const id = options?.id || `function-hook-${Date.now()}-${Math.random()}` + const hook: FunctionHook = { + type: 'function', + id, + timeout: options?.timeout || 5000, + callback, + errorMessage, + } + addHookToSession(setAppState, sessionId, event, matcher, hook) + return id +} + +/** + * Remove a function hook by ID from the session. + */ +export function removeFunctionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + hookId: string, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) + if (!store) { + return prev + } + + const eventMatchers = store.hooks[event] || [] + + // Remove the hook with matching ID from all matchers + const updatedMatchers = eventMatchers + .map(matcher => { + const updatedHooks = matcher.hooks.filter(h => { + if (h.hook.type !== 'function') return true + return h.hook.id !== hookId + }) + + return updatedHooks.length > 0 + ? { ...matcher, hooks: updatedHooks } + : null + }) + .filter((m): m is SessionHookMatcher => m !== null) + + const newHooks = + updatedMatchers.length > 0 + ? { ...store.hooks, [event]: updatedMatchers } + : Object.fromEntries( + Object.entries(store.hooks).filter(([e]) => e !== event), + ) + + prev.sessionHooks.set(sessionId, { hooks: newHooks }) + return prev + }) + + logForDebugging( + `Removed function hook ${hookId} for event ${event} in session ${sessionId}`, + ) +} + +/** + * Internal helper to add a hook to session state + */ +function addHookToSession( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand | FunctionHook, + onHookSuccess?: OnHookSuccess, + skillRoot?: string, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} } + const eventMatchers = store.hooks[event] || [] + + // Find existing matcher or create new one + const existingMatcherIndex = eventMatchers.findIndex( + m => m.matcher === matcher && m.skillRoot === skillRoot, + ) + + let updatedMatchers: SessionHookMatcher[] + if (existingMatcherIndex >= 0) { + // Add to existing matcher + updatedMatchers = [...eventMatchers] + const existingMatcher = updatedMatchers[existingMatcherIndex]! + updatedMatchers[existingMatcherIndex] = { + matcher: existingMatcher.matcher, + skillRoot: existingMatcher.skillRoot, + hooks: [...existingMatcher.hooks, { hook, onHookSuccess }], + } + } else { + // Create new matcher + updatedMatchers = [ + ...eventMatchers, + { + matcher, + skillRoot, + hooks: [{ hook, onHookSuccess }], + }, + ] + } + + const newHooks = { ...store.hooks, [event]: updatedMatchers } + + prev.sessionHooks.set(sessionId, { hooks: newHooks }) + return prev + }) + + logForDebugging( + `Added session hook for event ${event} in session ${sessionId}`, + ) +} + +/** + * Remove a specific hook from the session + * @param setAppState The function to update the app state + * @param sessionId The session ID + * @param event The hook event + * @param hook The hook command to remove + */ +export function removeSessionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + hook: HookCommand, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) + if (!store) { + return prev + } + + const eventMatchers = store.hooks[event] || [] + + // Remove the hook from all matchers + const updatedMatchers = eventMatchers + .map(matcher => { + const updatedHooks = matcher.hooks.filter( + h => !isHookEqual(h.hook, hook), + ) + + return updatedHooks.length > 0 + ? { ...matcher, hooks: updatedHooks } + : null + }) + .filter((m): m is SessionHookMatcher => m !== null) + + const newHooks = + updatedMatchers.length > 0 + ? { ...store.hooks, [event]: updatedMatchers } + : { ...store.hooks } + + if (updatedMatchers.length === 0) { + delete newHooks[event] + } + + prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks }) + return prev + }) + + logForDebugging( + `Removed session hook for event ${event} in session ${sessionId}`, + ) +} + +// Extended hook matcher that includes optional skillRoot for skill-scoped hooks +export type SessionDerivedHookMatcher = { + matcher: string + hooks: HookCommand[] + skillRoot?: string +} + +/** + * Convert session hook matchers to regular hook matchers + * @param sessionMatchers The session hook matchers to convert + * @returns Regular hook matchers (with optional skillRoot preserved) + */ +function convertToHookMatchers( + sessionMatchers: SessionHookMatcher[], +): SessionDerivedHookMatcher[] { + return sessionMatchers.map(sm => ({ + matcher: sm.matcher, + skillRoot: sm.skillRoot, + // Filter out function hooks - they can't be persisted to HookMatcher format + hooks: sm.hooks + .map(h => h.hook) + .filter((h): h is HookCommand => h.type !== 'function'), + })) +} + +/** + * Get all session hooks for a specific event (excluding function hooks) + * @param appState The app state + * @param sessionId The session ID + * @param event Optional event to filter by + * @returns Hook matchers for the event, or all hooks if no event specified + */ +export function getSessionHooks( + appState: AppState, + sessionId: string, + event?: HookEvent, +): Map { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return new Map() + } + + const result = new Map() + + if (event) { + const sessionMatchers = store.hooks[event] + if (sessionMatchers) { + result.set(event, convertToHookMatchers(sessionMatchers)) + } + return result + } + + for (const evt of HOOK_EVENTS) { + const sessionMatchers = store.hooks[evt] + if (sessionMatchers) { + result.set(evt, convertToHookMatchers(sessionMatchers)) + } + } + + return result +} + +type FunctionHookMatcher = { + matcher: string + hooks: FunctionHook[] +} + +/** + * Get all session function hooks for a specific event + * Function hooks are kept separate because they can't be persisted to HookMatcher format. + * @param appState The app state + * @param sessionId The session ID + * @param event Optional event to filter by + * @returns Function hook matchers for the event + */ +export function getSessionFunctionHooks( + appState: AppState, + sessionId: string, + event?: HookEvent, +): Map { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return new Map() + } + + const result = new Map() + + const extractFunctionHooks = ( + sessionMatchers: SessionHookMatcher[], + ): FunctionHookMatcher[] => { + return sessionMatchers + .map(sm => ({ + matcher: sm.matcher, + hooks: sm.hooks + .map(h => h.hook) + .filter((h): h is FunctionHook => h.type === 'function'), + })) + .filter(m => m.hooks.length > 0) + } + + if (event) { + const sessionMatchers = store.hooks[event] + if (sessionMatchers) { + const functionMatchers = extractFunctionHooks(sessionMatchers) + if (functionMatchers.length > 0) { + result.set(event, functionMatchers) + } + } + return result + } + + for (const evt of HOOK_EVENTS) { + const sessionMatchers = store.hooks[evt] + if (sessionMatchers) { + const functionMatchers = extractFunctionHooks(sessionMatchers) + if (functionMatchers.length > 0) { + result.set(evt, functionMatchers) + } + } + } + + return result +} + +/** + * Get the full hook entry (including callbacks) for a specific session hook + */ +export function getSessionHookCallback( + appState: AppState, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand | FunctionHook, +): + | { + hook: HookCommand | FunctionHook + onHookSuccess?: OnHookSuccess + } + | undefined { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return undefined + } + + const eventMatchers = store.hooks[event] + if (!eventMatchers) { + return undefined + } + + // Find the hook in the matchers + for (const matcherEntry of eventMatchers) { + if (matcherEntry.matcher === matcher || matcher === '') { + const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook)) + if (hookEntry) { + return hookEntry + } + } + } + + return undefined +} + +/** + * Clear all session hooks for a specific session + * @param setAppState The function to update the app state + * @param sessionId The session ID + */ +export function clearSessionHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, +): void { + setAppState(prev => { + prev.sessionHooks.delete(sessionId) + return prev + }) + + logForDebugging(`Cleared all session hooks for session ${sessionId}`) +} + + +======================================== +FILE: src_utils_hooks_execPromptHook.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execPromptHook.ts +LINES: 254 +========== +import { randomUUID } from 'crypto' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { queryModelWithoutStreaming } from '../../services/api/claude.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import { isGoalPromptHookCommand } from '../../goals/goalState.js' +import { createAttachmentMessage } from '../attachments.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import type { HookResult } from '../hooks.js' +import { safeParseJSON } from '../json.js' +import { createUserMessage, extractTextContent } from '../messages.js' +import { getSmallFastModel } from '../model/model.js' +import type { PromptHook } from '../settings/types.js' +import { asSystemPrompt } from '../systemPromptType.js' +import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js' + +function goalHookFailureResult( + hook: PromptHook, + reason: string, +): HookResult | null { + if (!isGoalPromptHookCommand(hook.prompt)) return null + + const blockingError = `Goal evaluator failed: ${reason}. Treat the goal as incomplete and continue working toward it.` + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError, + command: hook.prompt, + }, + preventContinuation: true, + stopReason: blockingError, + } +} + +/** + * Execute a prompt-based hook using an LLM + */ +export async function execPromptHook( + hook: PromptHook, + hookName: string, + hookEvent: HookEvent, + jsonInput: string, + signal: AbortSignal, + toolUseContext: ToolUseContext, + messages?: Message[], + toolUseID?: string, +): Promise { + // Use provided toolUseID or generate a new one + const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` + try { + // Replace $ARGUMENTS with the JSON input + const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) + logForDebugging( + `Hooks: Processing prompt hook with prompt: ${processedPrompt}`, + ) + + // Create user message directly - no need for processUserInput which would + // trigger UserPromptSubmit hooks and cause infinite recursion + const userMessage = createUserMessage({ content: processedPrompt }) + + // Prepend conversation history if provided + const messagesToQuery = + messages && messages.length > 0 + ? [...messages, userMessage] + : [userMessage] + + logForDebugging( + `Hooks: Querying model with ${messagesToQuery.length} messages`, + ) + + // Query the model with Haiku + const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000 + + // Combined signal: aborts if either the hook signal or timeout triggers + const { signal: combinedSignal, cleanup: cleanupSignal } = + createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) + + try { + const response = await queryModelWithoutStreaming({ + messages: messagesToQuery, + systemPrompt: asSystemPrompt([ + `You are evaluating a hook in Claude Code. + +Your response must be a JSON object matching one of the following schemas: +1. If the condition is met, return: {"ok": true} +2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`, + ]), + thinkingConfig: { type: 'disabled' as const }, + tools: toolUseContext.options.tools, + signal: combinedSignal, + options: { + async getToolPermissionContext() { + const appState = toolUseContext.getAppState() + return appState.toolPermissionContext + }, + model: hook.model ?? getSmallFastModel(), + toolChoice: undefined, + isNonInteractiveSession: true, + hasAppendSystemPrompt: false, + agents: [], + querySource: 'hook_prompt', + mcpTools: [], + agentId: toolUseContext.agentId, + outputFormat: { + type: 'json_schema', + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['ok'], + additionalProperties: false, + }, + }, + }, + }) + + cleanupSignal() + + // Extract text content from response + const content = extractTextContent(response.message.content) + + // Update response length for spinner display + toolUseContext.setResponseLength(length => length + content.length) + + const fullResponse = content.trim() + logForDebugging(`Hooks: Model response: ${fullResponse}`) + + const json = safeParseJSON(fullResponse) + if (!json) { + logForDebugging( + `Hooks: error parsing response as JSON: ${fullResponse}`, + ) + const goalFailure = goalHookFailureResult( + hook, + 'response was not valid JSON', + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: 'JSON validation failed', + stdout: fullResponse, + exitCode: 1, + }), + } + } + + const parsed = hookResponseSchema().safeParse(json) + if (!parsed.success) { + logForDebugging( + `Hooks: model response does not conform to expected schema: ${parsed.error.message}`, + ) + const goalFailure = goalHookFailureResult( + hook, + `response did not match the expected schema (${parsed.error.message})`, + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Schema validation failed: ${parsed.error.message}`, + stdout: fullResponse, + exitCode: 1, + }), + } + } + + // Failed to meet condition + if (!parsed.data.ok) { + logForDebugging( + `Hooks: Prompt hook condition was not met: ${parsed.data.reason}`, + ) + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`, + command: hook.prompt, + }, + preventContinuation: true, + stopReason: parsed.data.reason, + } + } + + // Condition was met + logForDebugging(`Hooks: Prompt hook condition was met`) + return { + hook, + outcome: 'success', + message: createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + content: '', + }), + } + } catch (error) { + cleanupSignal() + + if (combinedSignal.aborted) { + const goalFailure = signal.aborted + ? null + : goalHookFailureResult(hook, 'evaluation timed out') + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'cancelled', + } + } + throw error + } + } catch (error) { + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`) + const goalFailure = goalHookFailureResult( + hook, + errorMsg || 'evaluation failed', + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Error executing prompt hook: ${errorMsg}`, + stdout: '', + exitCode: 1, + }), + } + } +} + + +======================================== +FILE: src_utils_hooks_execAgentHook.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execAgentHook.ts +LINES: 339 +========== +import { randomUUID } from 'crypto' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { query } from '../../query.js' +import { logEvent } from '../../services/analytics/index.js' +import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' +import type { ToolUseContext } from '../../Tool.js' +import { type Tool, toolMatchesName } from '../../Tool.js' +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' +import { asAgentId } from '../../types/ids.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../abortController.js' +import { createAttachmentMessage } from '../attachments.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import type { HookResult } from '../hooks.js' +import { createUserMessage, handleMessageFromStream } from '../messages.js' +import { getSmallFastModel } from '../model/model.js' +import { hasPermissionsToUseTool } from '../permissions/permissions.js' +import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' +import type { AgentHook } from '../settings/types.js' +import { jsonStringify } from '../slowOperations.js' +import { asSystemPrompt } from '../systemPromptType.js' +import { + addArgumentsToPrompt, + createStructuredOutputTool, + hookResponseSchema, + registerStructuredOutputEnforcement, +} from './hookHelpers.js' +import { clearSessionHooks } from './sessionHooks.js' + +/** + * Execute an agent-based hook using a multi-turn LLM query + */ +export async function execAgentHook( + hook: AgentHook, + hookName: string, + hookEvent: HookEvent, + jsonInput: string, + signal: AbortSignal, + toolUseContext: ToolUseContext, + toolUseID: string | undefined, + // Kept for signature stability with the other exec*Hook functions. + // Was used by hook.prompt(messages) before the .transform() was removed + // (CC-79) 鈥?the only consumer of that was ExitPlanModeV2Tool's + // programmatic construction, since refactored into VerifyPlanExecutionTool. + _messages: Message[], + agentName?: string, +): Promise { + const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` + + // Get transcript path from context + const transcriptPath = toolUseContext.agentId + ? getAgentTranscriptPath(toolUseContext.agentId) + : getTranscriptPath() + const hookStartTime = Date.now() + try { + // Replace $ARGUMENTS with the JSON input + const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) + logForDebugging( + `Hooks: Processing agent hook with prompt: ${processedPrompt}`, + ) + + // Create user message directly - no need for processUserInput which would + // trigger UserPromptSubmit hooks and cause infinite recursion + const userMessage = createUserMessage({ content: processedPrompt }) + const agentMessages = [userMessage] + + logForDebugging( + `Hooks: Starting agent query with ${agentMessages.length} messages`, + ) + + // Setup timeout and combine with parent signal + const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 + const hookAbortController = createAbortController() + + // Combine parent signal with timeout, and have it abort our controller + const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = + createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) + const onParentTimeout = () => hookAbortController.abort() + parentTimeoutSignal.addEventListener('abort', onParentTimeout) + + // Combined signal is just our controller's signal now + const combinedSignal = hookAbortController.signal + + try { + // Create StructuredOutput tool with our schema + const structuredOutputTool = createStructuredOutputTool() + + // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas + // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) + const filteredTools = toolUseContext.options.tools.filter( + tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), + ) + + // Use all available tools plus our structured output tool + // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents + // or entering plan mode, and filter out duplicate StructuredOutput tools + const tools: Tool[] = [ + ...filteredTools.filter( + tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), + ), + structuredOutputTool, + ] + + const systemPrompt = asSystemPrompt([ + `You are verifying a stop condition in Claude Code. Your task is to verify that the agent completed the given plan. The conversation transcript is available at: ${transcriptPath}\nYou can read this file to analyze the conversation history if needed. + +Use the available tools to inspect the codebase and verify the condition. +Use as few steps as possible - be efficient and direct. + +When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: +- ok: true if the condition is met +- ok: false with reason if the condition is not met`, + ]) + + const model = hook.model ?? getSmallFastModel() + const MAX_AGENT_TURNS = 50 + + // Create unique agentId for this hook agent + const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) + + // Create a modified toolUseContext for the agent + const agentToolUseContext: ToolUseContext = { + ...toolUseContext, + agentId: hookAgentId, + abortController: hookAbortController, + options: { + ...toolUseContext.options, + tools, + mainLoopModel: model, + isNonInteractiveSession: true, + thinkingConfig: { type: 'disabled' as const }, + }, + setInProgressToolUseIDs: () => {}, + getAppState() { + const appState = toolUseContext.getAppState() + // Add session rule to allow reading transcript file + const existingSessionRules = + appState.toolPermissionContext.alwaysAllowRules.session ?? [] + return { + ...appState, + toolPermissionContext: { + ...appState.toolPermissionContext, + mode: 'dontAsk' as const, + alwaysAllowRules: { + ...appState.toolPermissionContext.alwaysAllowRules, + session: [...existingSessionRules, `Read(/${transcriptPath})`], + }, + }, + } + }, + } + + // Register a session-level stop hook to enforce structured output + registerStructuredOutputEnforcement( + toolUseContext.setAppState, + hookAgentId, + ) + + let structuredOutputResult: { ok: boolean; reason?: string } | null = null + let turnCount = 0 + let hitMaxTurns = false + + // Use query() for multi-turn execution + for await (const message of query({ + messages: agentMessages, + systemPrompt, + userContext: {}, + systemContext: {}, + canUseTool: hasPermissionsToUseTool, + toolUseContext: agentToolUseContext, + querySource: 'hook_agent', + })) { + // Process stream events to update response length in the spinner + handleMessageFromStream( + message, + () => {}, // onMessage - we handle messages below + newContent => + toolUseContext.setResponseLength( + length => length + newContent.length, + ), + toolUseContext.setStreamMode ?? (() => {}), + () => {}, // onStreamingToolUses - not needed for hooks + ) + + // Skip streaming events for further processing + if ( + message.type === 'stream_event' || + message.type === 'stream_request_start' + ) { + continue + } + + // Count assistant turns + if (message.type === 'assistant') { + turnCount++ + + // Check if we've hit the turn limit + if (turnCount >= MAX_AGENT_TURNS) { + hitMaxTurns = true + logForDebugging( + `Hooks: Agent turn ${turnCount} hit max turns, aborting`, + ) + hookAbortController.abort() + break + } + } + + // Check for structured output in attachments + if ( + message.type === 'attachment' && + message.attachment.type === 'structured_output' + ) { + const parsed = hookResponseSchema().safeParse(message.attachment.data) + if (parsed.success) { + structuredOutputResult = parsed.data + logForDebugging( + `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, + ) + // Got structured output, abort and exit + hookAbortController.abort() + break + } + } + } + + parentTimeoutSignal.removeEventListener('abort', onParentTimeout) + cleanupCombinedSignal() + + // Clean up the session hook we registered for this agent + clearSessionHooks(toolUseContext.setAppState, hookAgentId) + + // Check if we got a result + if (!structuredOutputResult) { + // If we hit max turns, just log and return cancelled (no UI message) + if (hitMaxTurns) { + logForDebugging( + `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, + ) + logEvent('tengu_agent_stop_hook_max_turns', { + durationMs: Date.now() - hookStartTime, + turnCount, + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'cancelled', + } + } + + // For other cases (e.g., agent finished without calling structured output tool), + // just log and return cancelled (don't show error to user) + logForDebugging(`Hooks: Agent hook did not return structured output`) + logEvent('tengu_agent_stop_hook_error', { + durationMs: Date.now() - hookStartTime, + turnCount, + errorType: 1, // 1 = no structured output + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'cancelled', + } + } + + // Return result based on structured output + if (!structuredOutputResult.ok) { + logForDebugging( + `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, + ) + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, + command: hook.prompt, + }, + } + } + + // Condition was met + logForDebugging(`Hooks: Agent hook condition was met`) + logEvent('tengu_agent_stop_hook_success', { + durationMs: Date.now() - hookStartTime, + turnCount, + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'success', + message: createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + content: '', + }), + } + } catch (error) { + parentTimeoutSignal.removeEventListener('abort', onParentTimeout) + cleanupCombinedSignal() + + if (combinedSignal.aborted) { + return { + hook, + outcome: 'cancelled', + } + } + throw error + } + } catch (error) { + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) + logEvent('tengu_agent_stop_hook_error', { + durationMs: Date.now() - hookStartTime, + errorType: 2, // 2 = general error + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Error executing agent hook: ${errorMsg}`, + stdout: '', + exitCode: 1, + }), + } + } +} + + +======================================== +FILE: src_utils_hooks_execHttpHook.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execHttpHook.ts +LINES: 242 +========== +import axios from 'axios' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import { getProxyUrl, shouldBypassProxy } from '../proxy.js' +// Import as namespace so spyOn works in tests (direct imports bypass spies) +import * as settingsModule from '../settings/settings.js' +import type { HttpHook } from '../settings/types.js' +import { ssrfGuardedLookup } from './ssrfGuard.js' + +const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS) + +/** + * Get the sandbox proxy config for routing HTTP hook requests through the + * sandbox network proxy when sandboxing is enabled. + * + * Uses dynamic import to avoid a static import cycle + * (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook). + */ +async function getSandboxProxyConfig(): Promise< + { host: string; port: number; protocol: string } | undefined +> { + const { SandboxManager } = await import('../sandbox/sandbox-adapter.js') + + if (!SandboxManager.isSandboxingEnabled()) { + return undefined + } + + // Wait for the sandbox network proxy to finish initializing. In REPL mode, + // SandboxManager.initialize() is fire-and-forget so the proxy may not be + // ready yet when the first hook fires. + await SandboxManager.waitForNetworkInitialization() + + const proxyPort = SandboxManager.getProxyPort() + if (!proxyPort) { + return undefined + } + + return { host: '127.0.0.1', port: proxyPort, protocol: 'http' } +} + +/** + * Read HTTP hook allowlist restrictions from merged settings (all sources). + * Follows the allowedMcpServers precedent: arrays concatenate across sources. + * When allowManagedHooksOnly is set in managed settings, only admin-defined + * hooks run anyway, so no separate lock-down boolean is needed here. + */ +function getHttpHookPolicy(): { + allowedUrls: string[] | undefined + allowedEnvVars: string[] | undefined +} { + const settings = settingsModule.getInitialSettings() + return { + allowedUrls: settings.allowedHttpHookUrls, + allowedEnvVars: settings.httpHookAllowedEnvVars, + } +} + +/** + * Match a URL against a pattern with * as a wildcard (any characters). + * Same semantics as the MCP server allowlist patterns. + */ +function urlMatchesPattern(url: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + const regexStr = escaped.replace(/\*/g, '.*') + return new RegExp(`^${regexStr}$`).test(url) +} + +/** + * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header + * injection (CRLF injection) via env var values or hook-configured header + * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise + * inject a second header into the request. + */ +function sanitizeHeaderValue(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\r\n\x00]/g, '') +} + +/** + * Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env, + * but only for variable names present in the allowlist. References to variables + * not in the allowlist are replaced with empty strings to prevent exfiltration + * of secrets via project-configured HTTP hooks. + * + * The result is sanitized to strip CR/LF/NUL bytes to prevent header injection. + */ +function interpolateEnvVars( + value: string, + allowedEnvVars: ReadonlySet, +): string { + const interpolated = value.replace( + /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, + (_, braced, unbraced) => { + const varName = braced ?? unbraced + if (!allowedEnvVars.has(varName)) { + logForDebugging( + `Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`, + { level: 'warn' }, + ) + return '' + } + return process.env[varName] ?? '' + }, + ) + return sanitizeHeaderValue(interpolated) +} + +/** + * Execute an HTTP hook by POSTing the hook input JSON to the configured URL. + * Returns the raw response for the caller to interpret. + * + * When sandboxing is enabled, requests are routed through the sandbox network + * proxy which enforces the domain allowlist. The proxy returns HTTP 403 for + * blocked domains. + * + * Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that + * secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json. + * Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved; + * all other references are replaced with empty strings. + */ +export async function execHttpHook( + hook: HttpHook, + _hookEvent: HookEvent, + jsonInput: string, + signal?: AbortSignal, +): Promise<{ + ok: boolean + statusCode?: number + body: string + error?: string + aborted?: boolean +}> { + // Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics: + // undefined 鈫?no restriction; [] 鈫?block all; non-empty 鈫?must match a pattern. + const policy = getHttpHookPolicy() + if (policy.allowedUrls !== undefined) { + const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p)) + if (!matched) { + const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls` + logForDebugging(msg, { level: 'warn' }) + return { ok: false, body: '', error: msg } + } + } + + const timeoutMs = hook.timeout + ? hook.timeout * 1000 + : DEFAULT_HTTP_HOOK_TIMEOUT_MS + + const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs }, + ) + + try { + // Build headers with env var interpolation in values + const headers: Record = { + 'Content-Type': 'application/json', + } + if (hook.headers) { + // Intersect hook's allowedEnvVars with policy allowlist when policy is set + const hookVars = hook.allowedEnvVars ?? [] + const effectiveVars = + policy.allowedEnvVars !== undefined + ? hookVars.filter(v => policy.allowedEnvVars!.includes(v)) + : hookVars + const allowedEnvVars = new Set(effectiveVars) + for (const [name, value] of Object.entries(hook.headers)) { + headers[name] = interpolateEnvVars(value, allowedEnvVars) + } + } + + // Route through sandbox network proxy when available. The proxy enforces + // the domain allowlist and returns 403 for blocked domains. + const sandboxProxy = await getSandboxProxyConfig() + + // Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY). + // When set, configureGlobalAgents() has already installed a request + // interceptor that sets httpsAgent to an HttpsProxyAgent 鈥?the proxy + // handles DNS for the target. Skip the SSRF guard in that case, same + // as we do for the sandbox proxy, so that we don't accidentally block + // a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128). + const envProxyActive = + !sandboxProxy && + getProxyUrl() !== undefined && + !shouldBypassProxy(hook.url) + + if (sandboxProxy) { + logForDebugging( + `Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`, + ) + } else if (envProxyActive) { + logForDebugging( + `Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`, + ) + } else { + logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`) + } + + const response = await axios.post(hook.url, jsonInput, { + headers, + signal: combinedSignal, + responseType: 'text', + validateStatus: () => true, + maxRedirects: 0, + // Explicit false prevents axios's own env-var proxy detection; when an + // env-var proxy is configured, the global axios interceptor installed + // by configureGlobalAgents() handles it via httpsAgent instead. + proxy: sandboxProxy ?? false, + // SSRF guard: validate resolved IPs, block private/link-local ranges + // (but allow loopback for local dev). Skipped when any proxy is in + // use 鈥?the proxy performs DNS for the target, and applying the + // guard would instead validate the proxy's own IP, breaking + // connections to corporate proxies on private networks. + lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup, + }) + + cleanup() + + const body = response.data ?? '' + logForDebugging( + `Hooks: HTTP hook response status ${response.status}, body length ${body.length}`, + ) + + return { + ok: response.status >= 200 && response.status < 300, + statusCode: response.status, + body, + } + } catch (error) { + cleanup() + + if (combinedSignal.aborted) { + return { ok: false, body: '', aborted: true } + } + + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' }) + return { ok: false, body: '', error: errorMsg } + } +} + + +======================================== +FILE: src_utils_hooks_AsyncHookRegistry.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\AsyncHookRegistry.ts +LINES: 309 +========== +import type { + AsyncHookJSONOutput, + HookEvent, + SyncHookJSONOutput, +} from 'src/entrypoints/agentSdkTypes.js' +import { logForDebugging } from '../debug.js' +import type { ShellCommand } from '../ShellCommand.js' +import { invalidateSessionEnvCache } from '../sessionEnvironment.js' +import { jsonParse, jsonStringify } from '../slowOperations.js' +import { emitHookResponse, startHookProgressInterval } from './hookEvents.js' + +export type PendingAsyncHook = { + processId: string + hookId: string + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + startTime: number + timeout: number + command: string + responseAttachmentSent: boolean + shellCommand?: ShellCommand + stopProgressInterval: () => void +} + +// Global registry state +const pendingHooks = new Map() + +export function registerPendingAsyncHook({ + processId, + hookId, + asyncResponse, + hookName, + hookEvent, + command, + shellCommand, + toolName, + pluginId, +}: { + processId: string + hookId: string + asyncResponse: AsyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + command: string + shellCommand: ShellCommand + toolName?: string + pluginId?: string +}): void { + const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s + logForDebugging( + `Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`, + ) + const stopProgressInterval = startHookProgressInterval({ + hookId, + hookName, + hookEvent, + getOutput: async () => { + const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput + if (!taskOutput) { + return { stdout: '', stderr: '', output: '' } + } + const stdout = await taskOutput.getStdout() + const stderr = taskOutput.getStderr() + return { stdout, stderr, output: stdout + stderr } + }, + }) + pendingHooks.set(processId, { + processId, + hookId, + hookName, + hookEvent, + toolName, + pluginId, + command, + startTime: Date.now(), + timeout, + responseAttachmentSent: false, + shellCommand, + stopProgressInterval, + }) +} + +export function getPendingAsyncHooks(): PendingAsyncHook[] { + return Array.from(pendingHooks.values()).filter( + hook => !hook.responseAttachmentSent, + ) +} + +async function finalizeHook( + hook: PendingAsyncHook, + exitCode: number, + outcome: 'success' | 'error' | 'cancelled', +): Promise { + hook.stopProgressInterval() + const taskOutput = hook.shellCommand?.taskOutput + const stdout = taskOutput ? await taskOutput.getStdout() : '' + const stderr = taskOutput?.getStderr() ?? '' + hook.shellCommand?.cleanup() + emitHookResponse({ + hookId: hook.hookId, + hookName: hook.hookName, + hookEvent: hook.hookEvent, + output: stdout + stderr, + stdout, + stderr, + exitCode, + outcome, + }) +} + +export async function checkForAsyncHookResponses(): Promise< + Array<{ + processId: string + response: SyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + stdout: string + stderr: string + exitCode?: number + }> +> { + const responses: { + processId: string + response: SyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + stdout: string + stderr: string + exitCode?: number + }[] = [] + + const pendingCount = pendingHooks.size + logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`) + + // Snapshot hooks before processing 鈥?we'll mutate the map after. + const hooks = Array.from(pendingHooks.values()) + + const settled = await Promise.allSettled( + hooks.map(async hook => { + const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? '' + const stderr = hook.shellCommand?.taskOutput.getStderr() ?? '' + logForDebugging( + `Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`, + ) + + if (!hook.shellCommand) { + logForDebugging( + `Hooks: Hook ${hook.processId} has no shell command, removing from registry`, + ) + hook.stopProgressInterval() + return { type: 'remove' as const, processId: hook.processId } + } + + logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`) + + if (hook.shellCommand.status === 'killed') { + logForDebugging( + `Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`, + ) + hook.stopProgressInterval() + hook.shellCommand.cleanup() + return { type: 'remove' as const, processId: hook.processId } + } + + if (hook.shellCommand.status !== 'completed') { + return { type: 'skip' as const } + } + + if (hook.responseAttachmentSent || !stdout.trim()) { + logForDebugging( + `Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`, + ) + hook.stopProgressInterval() + return { type: 'remove' as const, processId: hook.processId } + } + + const lines = stdout.split('\n') + logForDebugging( + `Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`, + ) + + const execResult = await hook.shellCommand.result + const exitCode = execResult.code + + let response: SyncHookJSONOutput = {} + for (const line of lines) { + if (line.trim().startsWith('{')) { + logForDebugging( + `Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`, + ) + try { + const parsed = jsonParse(line.trim()) + if (!('async' in parsed)) { + logForDebugging( + `Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`, + ) + response = parsed + break + } + } catch { + logForDebugging( + `Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`, + ) + } + } + } + + hook.responseAttachmentSent = true + await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error') + + return { + type: 'response' as const, + processId: hook.processId, + isSessionStart: hook.hookEvent === 'SessionStart', + payload: { + processId: hook.processId, + response, + hookName: hook.hookName, + hookEvent: hook.hookEvent, + toolName: hook.toolName, + pluginId: hook.pluginId, + stdout, + stderr, + exitCode, + }, + } + }), + ) + + // allSettled 鈥?isolate failures so one throwing callback doesn't orphan + // already-applied side effects (responseAttachmentSent, finalizeHook) from others. + let sessionStartCompleted = false + for (const s of settled) { + if (s.status !== 'fulfilled') { + logForDebugging( + `Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`, + { level: 'error' }, + ) + continue + } + const r = s.value + if (r.type === 'remove') { + pendingHooks.delete(r.processId) + } else if (r.type === 'response') { + responses.push(r.payload) + pendingHooks.delete(r.processId) + if (r.isSessionStart) sessionStartCompleted = true + } + } + + if (sessionStartCompleted) { + logForDebugging( + `Invalidating session env cache after SessionStart hook completed`, + ) + invalidateSessionEnvCache() + } + + logForDebugging( + `Hooks: checkForNewResponses returning ${responses.length} responses`, + ) + return responses +} + +export function removeDeliveredAsyncHooks(processIds: string[]): void { + for (const processId of processIds) { + const hook = pendingHooks.get(processId) + if (hook && hook.responseAttachmentSent) { + logForDebugging(`Hooks: Removing delivered hook ${processId}`) + hook.stopProgressInterval() + pendingHooks.delete(processId) + } + } +} + +export async function finalizePendingAsyncHooks(): Promise { + const hooks = Array.from(pendingHooks.values()) + await Promise.all( + hooks.map(async hook => { + if (hook.shellCommand?.status === 'completed') { + const result = await hook.shellCommand.result + await finalizeHook( + hook, + result.code, + result.code === 0 ? 'success' : 'error', + ) + } else { + if (hook.shellCommand && hook.shellCommand.status !== 'killed') { + hook.shellCommand.kill() + } + await finalizeHook(hook, 1, 'cancelled') + } + }), + ) + pendingHooks.clear() +} + +// Test utility function to clear all hooks +export function clearAllAsyncHooks(): void { + for (const hook of pendingHooks.values()) { + hook.stopProgressInterval() + } + pendingHooks.clear() +} + + +======================================== +FILE: src_utils_hooks_postSamplingHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\postSamplingHooks.ts +LINES: 70 +========== +import type { QuerySource } from '../../constants/querySource.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import { toError } from '../errors.js' +import { logError } from '../log.js' +import type { SystemPrompt } from '../systemPromptType.js' + +// Post-sampling hook - not exposed in settings.json config (yet), only used programmatically + +// Generic context for REPL hooks (both post-sampling and stop hooks) +export type REPLHookContext = { + messages: Message[] // Full message history including assistant responses + systemPrompt: SystemPrompt + userContext: { [k: string]: string } + systemContext: { [k: string]: string } + toolUseContext: ToolUseContext + querySource?: QuerySource +} + +export type PostSamplingHook = ( + context: REPLHookContext, +) => Promise | void + +// Internal registry for post-sampling hooks +const postSamplingHooks: PostSamplingHook[] = [] + +/** + * Register a post-sampling hook that will be called after model sampling completes + * This is an internal API not exposed through settings + */ +export function registerPostSamplingHook(hook: PostSamplingHook): void { + postSamplingHooks.push(hook) +} + +/** + * Clear all registered post-sampling hooks (for testing) + */ +export function clearPostSamplingHooks(): void { + postSamplingHooks.length = 0 +} + +/** + * Execute all registered post-sampling hooks + */ +export async function executePostSamplingHooks( + messages: Message[], + systemPrompt: SystemPrompt, + userContext: { [k: string]: string }, + systemContext: { [k: string]: string }, + toolUseContext: ToolUseContext, + querySource?: QuerySource, +): Promise { + const context: REPLHookContext = { + messages, + systemPrompt, + userContext, + systemContext, + toolUseContext, + querySource, + } + + for (const hook of postSamplingHooks) { + try { + await hook(context) + } catch (error) { + // Log but don't fail on hook errors + logError(toError(error)) + } + } +} + + +======================================== +FILE: src_services_tools_toolHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\services\tools\toolHooks.ts +LINES: 652 +========== +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' +import type z from 'zod/v4' +import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' +import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' +import type { HookProgress } from '../../types/hooks.js' +import type { + AssistantMessage, + AttachmentMessage, + ProgressMessage, +} from '../../types/message.js' +import type { PermissionDecision } from '../../types/permissions.js' +import { createAttachmentMessage } from '../../utils/attachments.js' +import { logForDebugging } from '../../utils/debug.js' +import { + executePostToolHooks, + executePostToolUseFailureHooks, + executePreToolHooks, + getPreToolHookBlockingMessage, +} from '../../utils/hooks.js' +import { logError } from '../../utils/log.js' +import { + getRuleBehaviorDescription, + type PermissionDecisionReason, + type PermissionResult, +} from '../../utils/permissions/PermissionResult.js' +import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' +import { formatError } from '../../utils/toolErrors.js' +import { isMcpTool } from '../mcp/utils.js' +import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' + +export type PostToolUseHooksResult = + | MessageUpdateLazy> + | { updatedMCPToolOutput: Output } + +export async function* runPostToolUseHooks( + toolUseContext: ToolUseContext, + tool: Tool, + toolUseID: string, + messageId: string, + toolInput: Record, + toolResponse: Output, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator> { + const postToolStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + let toolOutput = toolResponse + for await (const result of executePostToolHooks( + tool.name, + toolUseID, + toolInput, + toolOutput, + toolUseContext, + permissionMode, + toolUseContext.abortController.signal, + )) { + try { + // Check if we were aborted during hook execution + // IMPORTANT: We emit a cancelled event per hook + if ( + result.message?.type === 'attachment' && + result.message.attachment.type === 'hook_cancelled' + ) { + logEvent('tengu_post_tool_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PostToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUse', + }), + } + continue + } + + // For JSON {decision:"block"} hooks, executeHooks yields two results: + // {blockingError} and {message: hook_blocking_error attachment}. The + // blockingError path below creates that same attachment, so skip it + // here to avoid displaying the block reason twice (#31301). The + // exit-code-2 path only yields {blockingError}, so it's unaffected. + if ( + result.message && + !( + result.message.type === 'attachment' && + result.message.attachment.type === 'hook_blocking_error' + ) + ) { + yield { message: result.message } + } + + if (result.blockingError) { + yield { + message: createAttachmentMessage({ + type: 'hook_blocking_error', + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + blockingError: result.blockingError, + }), + } + } + + // If hook indicated to prevent continuation, yield a stop reason message + if (result.preventContinuation) { + yield { + message: createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: + result.stopReason || 'Execution stopped by PostToolUse hook', + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + return + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + } + + // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool + if (result.updatedMCPToolOutput && isMcpTool(tool)) { + toolOutput = result.updatedMCPToolOutput as Output + yield { + updatedMCPToolOutput: toolOutput, + } + } + } catch (error) { + const postToolDurationMs = Date.now() - postToolStartTime + logEvent('tengu_post_tool_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: postToolDurationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(error), + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + } + } + } catch (error) { + logError(error) + } +} + +export async function* runPostToolUseFailureHooks( + toolUseContext: ToolUseContext, + tool: Tool, + toolUseID: string, + messageId: string, + processedInput: z.infer, + error: string, + isInterrupt: boolean | undefined, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator< + MessageUpdateLazy> +> { + const postToolStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + for await (const result of executePostToolUseFailureHooks( + tool.name, + toolUseID, + processedInput, + error, + toolUseContext, + isInterrupt, + permissionMode, + toolUseContext.abortController.signal, + )) { + try { + // Check if we were aborted during hook execution + if ( + result.message?.type === 'attachment' && + result.message.attachment.type === 'hook_cancelled' + ) { + logEvent('tengu_post_tool_failure_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + continue + } + + // Skip hook_blocking_error in result.message 鈥?blockingError path + // below creates the same attachment (see #31301 / PostToolUse above). + if ( + result.message && + !( + result.message.type === 'attachment' && + result.message.attachment.type === 'hook_blocking_error' + ) + ) { + yield { message: result.message } + } + + if (result.blockingError) { + yield { + message: createAttachmentMessage({ + type: 'hook_blocking_error', + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + blockingError: result.blockingError, + }), + } + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + } + } catch (hookError) { + const postToolDurationMs = Date.now() - postToolStartTime + logEvent('tengu_post_tool_failure_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: postToolDurationMs, + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(hookError), + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + } + } + } catch (outerError) { + logError(outerError) + } +} + +/** + * Resolve a PreToolUse hook's permission result into a final PermissionDecision. + * + * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json + * deny rules or non-bypass ask rules 鈥?checkRuleBasedPermissions still applies + * (inc-4788 analog). + * Also handles the requiresUserInteraction/requireCanUseTool guards and the + * 'ask' forceDecision passthrough. + * + * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts + * (REPL inner calls) so the permission semantics stay in lockstep. + */ +export async function resolveHookPermissionDecision( + hookPermissionResult: PermissionResult | undefined, + tool: Tool, + input: Record, + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + assistantMessage: AssistantMessage, + toolUseID: string, +): Promise<{ + decision: PermissionDecision + input: Record +}> { + const requiresInteraction = tool.requiresUserInteraction?.() + const requireCanUseTool = toolUseContext.requireCanUseTool + + if (hookPermissionResult?.behavior === 'allow') { + const hookInput = hookPermissionResult.updatedInput ?? input + + // Hook provided updatedInput for an interactive tool 鈥?the hook IS the + // user interaction (e.g. headless wrapper that collected AskUserQuestion + // answers). Treat as non-interactive for the rule-check path. + const interactionSatisfied = + requiresInteraction && hookPermissionResult.updatedInput !== undefined + + if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { + logForDebugging( + `Hook approved tool use for ${tool.name}, but canUseTool is required`, + ) + return { + decision: await canUseTool( + tool, + hookInput, + toolUseContext, + assistantMessage, + toolUseID, + ), + input: hookInput, + } + } + + // Hook allow skips the interactive prompt, but deny rules and non-bypass + // ask rules still apply. + const ruleCheck = await checkRuleBasedPermissions( + tool, + hookInput, + toolUseContext, + ) + if (ruleCheck === null) { + logForDebugging( + interactionSatisfied + ? `Hook satisfied user interaction for ${tool.name} via updatedInput` + : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, + ) + return { decision: hookPermissionResult, input: hookInput } + } + if (ruleCheck.behavior === 'deny') { + logForDebugging( + `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, + ) + return { decision: ruleCheck, input: hookInput } + } + // ask rule 鈥?dialog required despite hook approval + logForDebugging( + `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, + ) + return { + decision: await canUseTool( + tool, + hookInput, + toolUseContext, + assistantMessage, + toolUseID, + ), + input: hookInput, + } + } + + if (hookPermissionResult?.behavior === 'deny') { + logForDebugging(`Hook denied tool use for ${tool.name}`) + return { decision: hookPermissionResult, input } + } + + // No hook decision or 'ask' 鈥?normal permission flow, possibly with + // forceDecision so the dialog shows the hook's ask message. + const forceDecision = + hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined + const askInput = + hookPermissionResult?.behavior === 'ask' && + hookPermissionResult.updatedInput + ? hookPermissionResult.updatedInput + : input + return { + decision: await canUseTool( + tool, + askInput, + toolUseContext, + assistantMessage, + toolUseID, + forceDecision, + ), + input: askInput, + } +} + +export async function* runPreToolUseHooks( + toolUseContext: ToolUseContext, + tool: Tool, + processedInput: Record, + toolUseID: string, + messageId: string, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator< + | { + type: 'message' + message: MessageUpdateLazy< + AttachmentMessage | ProgressMessage + > + } + | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } + | { type: 'hookUpdatedInput'; updatedInput: Record } + | { type: 'preventContinuation'; shouldPreventContinuation: boolean } + | { type: 'stopReason'; stopReason: string } + | { + type: 'additionalContext' + message: MessageUpdateLazy + } + // stop execution + | { type: 'stop' } +> { + const hookStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + + for await (const result of executePreToolHooks( + tool.name, + toolUseID, + processedInput, + toolUseContext, + appState.toolPermissionContext.mode, + toolUseContext.abortController.signal, + undefined, // timeoutMs - use default + toolUseContext.requestPrompt, + tool.getToolUseSummary?.(processedInput), + )) { + try { + if (result.message) { + yield { type: 'message', message: { message: result.message } } + } + if (result.blockingError) { + const denialMessage = getPreToolHookBlockingMessage( + `PreToolUse:${tool.name}`, + result.blockingError, + ) + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'deny', + message: denialMessage, + decisionReason: { + type: 'hook', + hookName: `PreToolUse:${tool.name}`, + reason: denialMessage, + }, + }, + } + } + // Check if hook wants to prevent continuation + if (result.preventContinuation) { + yield { + type: 'preventContinuation', + shouldPreventContinuation: true, + } + if (result.stopReason) { + yield { type: 'stopReason', stopReason: result.stopReason } + } + } + // Check for hook-defined permission behavior + if (result.permissionBehavior !== undefined) { + logForDebugging( + `Hook result has permissionBehavior=${result.permissionBehavior}`, + ) + const decisionReason: PermissionDecisionReason = { + type: 'hook', + hookName: `PreToolUse:${tool.name}`, + hookSource: result.hookSource, + reason: result.hookPermissionDecisionReason, + } + if (result.permissionBehavior === 'allow') { + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'allow', + updatedInput: result.updatedInput, + decisionReason, + }, + } + } else if (result.permissionBehavior === 'ask') { + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'ask', + updatedInput: result.updatedInput, + message: + result.hookPermissionDecisionReason || + `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, + decisionReason, + }, + } + } else { + // deny - updatedInput is irrelevant since tool won't run + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: result.permissionBehavior, + message: + result.hookPermissionDecisionReason || + `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, + decisionReason, + }, + } + } + } + + // Yield updatedInput for passthrough case (no permission decision) + // This allows hooks to modify input while letting normal permission flow continue + if (result.updatedInput && result.permissionBehavior === undefined) { + yield { + type: 'hookUpdatedInput', + updatedInput: result.updatedInput, + } + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + type: 'additionalContext', + message: { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PreToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + } + + // Check if we were aborted during hook execution + if (toolUseContext.abortController.signal.aborted) { + logEvent('tengu_pre_tool_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + type: 'message', + message: { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PreToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + yield { type: 'stop' } + return + } + } catch (error) { + logError(error) + const durationMs = Date.now() - hookStartTime + logEvent('tengu_pre_tool_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: durationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + type: 'message', + message: { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(error), + hookName: `PreToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + yield { type: 'stop' } + } + } + } catch (error) { + logError(error) + yield { type: 'stop' } + return + } +} + + +======================================== +FILE: src_utils_hooks_hooksConfigSnapshot.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigSnapshot.ts +LINES: 133 +========== +import { resetSdkInitState } from '../../bootstrap/state.js' +import { isRestrictedToPluginOnly } from '../settings/pluginOnlyPolicy.js' +// Import as module object so spyOn works in tests (direct imports bypass spies) +import * as settingsModule from '../settings/settings.js' +import { resetSettingsCache } from '../settings/settingsCache.js' +import type { HooksSettings } from '../settings/types.js' + +let initialHooksConfig: HooksSettings | null = null + +/** + * Get hooks from allowed sources. + * If allowManagedHooksOnly is set in policySettings, only managed hooks are returned. + * If disableAllHooks is set in policySettings, no hooks are returned. + * If disableAllHooks is set in non-managed settings, only managed hooks are returned + * (non-managed settings cannot disable managed hooks). + * Otherwise, returns merged hooks from all sources (backwards compatible). + */ +function getHooksFromAllowedSources(): HooksSettings { + const policySettings = settingsModule.getSettingsForSource('policySettings') + + // If managed settings disables all hooks, return empty + if (policySettings?.disableAllHooks === true) { + return {} + } + + // If allowManagedHooksOnly is set in managed settings, only use managed hooks + if (policySettings?.allowManagedHooksOnly === true) { + return policySettings.hooks ?? {} + } + + // strictPluginOnlyCustomization: block user/project/local settings hooks. + // Plugin hooks (registered channel, hooks.ts:1391) are NOT affected 鈥? + // they're assembled separately and the managedOnly skip there is keyed + // on shouldAllowManagedHooksOnly(), not on this policy. Agent frontmatter + // hooks are gated at REGISTRATION (runAgent.ts:~535) by agent source 鈥? + // plugin/built-in/policySettings agents register normally, user-sourced + // agents skip registration under ["hooks"]. A blanket execution-time + // block here would over-kill plugin agents' hooks. + if (isRestrictedToPluginOnly('hooks')) { + return policySettings?.hooks ?? {} + } + + const mergedSettings = settingsModule.getSettings_DEPRECATED() + + // If disableAllHooks is set in non-managed settings, only managed hooks still run + // (non-managed settings cannot override managed hooks) + if (mergedSettings.disableAllHooks === true) { + return policySettings?.hooks ?? {} + } + + // Otherwise, use all hooks (merged from all sources) - backwards compatible + return mergedSettings.hooks ?? {} +} + +/** + * Check if only managed hooks should run. + * This is true when: + * - policySettings has allowManagedHooksOnly: true, OR + * - disableAllHooks is set in non-managed settings (non-managed settings + * cannot disable managed hooks, so they effectively become managed-only) + */ +export function shouldAllowManagedHooksOnly(): boolean { + const policySettings = settingsModule.getSettingsForSource('policySettings') + if (policySettings?.allowManagedHooksOnly === true) { + return true + } + // If disableAllHooks is set but NOT from managed settings, + // treat as managed-only (non-managed hooks disabled, managed hooks still run) + if ( + settingsModule.getSettings_DEPRECATED().disableAllHooks === true && + policySettings?.disableAllHooks !== true + ) { + return true + } + return false +} + +/** + * Check if all hooks (including managed) should be disabled. + * This is only true when managed/policy settings has disableAllHooks: true. + * When disableAllHooks is set in non-managed settings, managed hooks still run. + */ +export function shouldDisableAllHooksIncludingManaged(): boolean { + return ( + settingsModule.getSettingsForSource('policySettings')?.disableAllHooks === + true + ) +} + +/** + * Capture a snapshot of the current hooks configuration + * This should be called once during application startup + * Respects the allowManagedHooksOnly setting + */ +export function captureHooksConfigSnapshot(): void { + initialHooksConfig = getHooksFromAllowedSources() +} + +/** + * Update the hooks configuration snapshot + * This should be called when hooks are modified through the settings + * Respects the allowManagedHooksOnly setting + */ +export function updateHooksConfigSnapshot(): void { + // Reset the session cache to ensure we read fresh settings from disk. + // Without this, the snapshot could use stale cached settings when the user + // edits settings.json externally and then runs /hooks - the session cache + // may not have been invalidated yet (e.g., if the file watcher's stability + // threshold hasn't elapsed). + resetSettingsCache() + initialHooksConfig = getHooksFromAllowedSources() +} + +/** + * Get the current hooks configuration from snapshot + * Falls back to settings if no snapshot exists + * @returns The hooks configuration + */ +export function getHooksConfigFromSnapshot(): HooksSettings | null { + if (initialHooksConfig === null) { + captureHooksConfigSnapshot() + } + return initialHooksConfig +} + +/** + * Reset the hooks configuration snapshot (useful for testing) + * Also resets SDK init state to prevent test pollution + */ +export function resetHooksConfigSnapshot(): void { + initialHooksConfig = null + resetSdkInitState() +} + + +======================================== +FILE: src_commands_hooks_hooks.tsx +======================================== +FILE: E:\Yuanban\cc-haha-src\src\commands\hooks\hooks.tsx +LINES: 13 +========== +import * as React from 'react'; +import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { getTools } from '../../tools.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +export const call: LocalJSXCommandCall = async (onDone, context) => { + logEvent('tengu_hooks_command', {}); + const appState = context.getAppState(); + const permissionContext = appState.toolPermissionContext; + const toolNames = getTools(permissionContext).map(tool => tool.name); + return ; +}; +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119 + + +======================================== +FILE: src_costHook.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\costHook.ts +LINES: 22 +========== +import { useEffect } from 'react' +import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js' +import { hasConsoleBillingAccess } from './utils/billing.js' +import type { FpsMetrics } from './utils/fpsTracker.js' + +export function useCostSummary( + getFpsMetrics?: () => FpsMetrics | undefined, +): void { + useEffect(() => { + const f = () => { + if (hasConsoleBillingAccess()) { + process.stdout.write('\n' + formatTotalCost() + '\n') + } + + saveCurrentSessionCosts(getFpsMetrics?.()) + } + process.on('exit', f) + return () => { + process.off('exit', f) + } + }, []) +} + + +======================================== +FILE: src_hooks_useDeferredHookMessages.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\hooks\useDeferredHookMessages.ts +LINES: 46 +========== +import { useCallback, useEffect, useRef } from 'react' +import type { HookResultMessage, Message } from '../types/message.js' + +/** + * Manages deferred SessionStart hook messages so the REPL can render + * immediately instead of blocking on hook execution (~500ms). + * + * Hook messages are injected asynchronously when the promise resolves. + * Returns a callback that onSubmit should call before the first API + * request to ensure the model always sees hook context. + */ +export function useDeferredHookMessages( + pendingHookMessages: Promise | undefined, + setMessages: (action: React.SetStateAction) => void, +): () => Promise { + const pendingRef = useRef(pendingHookMessages ?? null) + const resolvedRef = useRef(!pendingHookMessages) + + useEffect(() => { + const promise = pendingRef.current + if (!promise) return + let cancelled = false + promise.then(msgs => { + if (cancelled) return + resolvedRef.current = true + pendingRef.current = null + if (msgs.length > 0) { + setMessages(prev => [...msgs, ...prev]) + } + }) + return () => { + cancelled = true + } + }, [setMessages]) + + return useCallback(async () => { + if (resolvedRef.current || !pendingRef.current) return + const msgs = await pendingRef.current + if (resolvedRef.current) return + resolvedRef.current = true + pendingRef.current = null + if (msgs.length > 0) { + setMessages(prev => [...msgs, ...prev]) + } + }, [setMessages]) +} + + +======================================== +FILE: src_query_stopHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.ts +LINES: 591 +========== +import { feature } from 'bun:bundle' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { getSessionId } from '../bootstrap/state.js' +import { isExtractModeActive } from '../memdir/paths.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import type { ToolUseContext } from '../Tool.js' +import { + ensureThreadGoalHookFromTranscript, + isGoalPromptHookCommand, +} from '../goals/goalState.js' +import type { HookResult } from '../types/hooks.js' +import type { HookProgress } from '../types/hooks.js' +import type { + AssistantMessage, + Message, + RequestStartEvent, + StopHookInfo, + StreamEvent, + TombstoneMessage, + ToolUseSummaryMessage, +} from '../types/message.js' +import { createAttachmentMessage } from '../utils/attachments.js' +import { logForDebugging } from '../utils/debug.js' +import { errorMessage } from '../utils/errors.js' +import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' +import { + executeStopHooks, + executeTaskCompletedHooks, + executeTeammateIdleHooks, + getStopHookMessage, + getTaskCompletedHookMessage, + getTeammateIdleHookMessage, +} from '../utils/hooks.js' +import { + createStopHookSummaryMessage, + createCommandInputMessage, + createSystemMessage, + createUserInterruptionMessage, + createUserMessage, +} from '../utils/messages.js' +import type { SystemPrompt } from '../utils/systemPromptType.js' +import { getTaskListId, listTasks } from '../utils/tasks.js' +import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js' + +/* eslint-disable @typescript-eslint/no-require-imports */ +const extractMemoriesModule = feature('EXTRACT_MEMORIES') + ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) + : null +const jobClassifierModule = feature('TEMPLATES') + ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) + : null + +/* eslint-enable @typescript-eslint/no-require-imports */ + +import type { QuerySource } from '../constants/querySource.js' +import { executeAutoDream } from '../services/autoDream/autoDream.js' +import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' +import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' +import { + createCacheSafeParams, + saveCacheSafeParams, +} from '../utils/forkedAgent.js' + +type StopHookResult = { + blockingErrors: Message[] + preventContinuation: boolean +} + +export function shouldLetGoalPromptHookContinue( + result: Pick, +): boolean { + return Boolean( + result.preventContinuation && + isGoalPromptHookCommand(result.blockingError?.command), + ) +} + +export function formatGoalContinuationStatusOutput(reason: string): string { + const normalizedReason = reason + .replace(/^Prompt hook condition was not met:\s*/i, '') + .replace(/[<>&]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 240) + + return normalizedReason + ? `Goal continuing: ${normalizedReason}` + : 'Goal continuing: more work is required' +} + +export async function* handleStopHooks( + messagesForQuery: Message[], + assistantMessages: AssistantMessage[], + systemPrompt: SystemPrompt, + userContext: { [k: string]: string }, + systemContext: { [k: string]: string }, + toolUseContext: ToolUseContext, + querySource: QuerySource, + stopHookActive?: boolean, +): AsyncGenerator< + | StreamEvent + | RequestStartEvent + | Message + | TombstoneMessage + | ToolUseSummaryMessage, + StopHookResult +> { + const hookStartTime = Date.now() + + const stopHookContext: REPLHookContext = { + messages: [...messagesForQuery, ...assistantMessages], + systemPrompt, + userContext, + systemContext, + toolUseContext, + querySource, + } + // Only save params for main session queries 鈥?subagents must not overwrite. + // Outside the prompt-suggestion gate: the REPL /btw command and the + // side_question SDK control_request both read this snapshot, and neither + // depends on prompt suggestions being enabled. + if (querySource === 'repl_main_thread' || querySource === 'sdk') { + saveCacheSafeParams(createCacheSafeParams(stopHookContext)) + } + + // Template job classification: when running as a dispatched job, classify + // state after each turn. Gate on repl_main_thread so background forks + // (extract-memories, auto-dream) don't pollute the timeline with their own + // assistant messages. Await the classifier so state.json is written before + // the turn returns 鈥?otherwise `claude list` shows stale state for the gap. + // Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the + // require()-gated jobs/ import pattern above; spawn.test.ts asserts the + // string matches. + if ( + feature('TEMPLATES') && + process.env.CLAUDE_JOB_DIR && + querySource.startsWith('repl_main_thread') && + !toolUseContext.agentId + ) { + // Full turn history 鈥?assistantMessages resets each queryLoop iteration, + // so tool calls from earlier iterations (Agent spawn, then summary) need + // messagesForQuery to be visible in the tool-call summary. + const turnAssistantMessages = stopHookContext.messages.filter( + (m): m is AssistantMessage => m.type === 'assistant', + ) + const p = jobClassifierModule! + .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) + .catch(err => { + logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { + level: 'error', + }) + }) + await Promise.race([ + p, + // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit + new Promise(r => setTimeout(r, 60_000).unref()), + ]) + } + // --bare / SIMPLE: skip background bookkeeping (prompt suggestion, + // memory extraction, auto-dream). Scripted -p calls don't want auto-memory + // or forked agents contending for resources during shutdown. + if (!isBareMode()) { + // Inline env check for dead code elimination in external builds + if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { + void executePromptSuggestion(stopHookContext) + } + if ( + feature('EXTRACT_MEMORIES') && + !toolUseContext.agentId && + isExtractModeActive() + ) { + // Fire-and-forget in both interactive and non-interactive. For -p/SDK, + // print.ts drains the in-flight promise after flushing the response + // but before gracefulShutdownSync (see drainPendingExtraction). + void extractMemoriesModule!.executeExtractMemories( + stopHookContext, + toolUseContext.appendSystemMessage, + ) + } + if (!toolUseContext.agentId) { + void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) + } + } + + // chicago MCP: auto-unhide + lock release at turn end. + // Main thread only 鈥?the CU lock is a process-wide module-level variable, + // so a subagent's stopHooks releasing it leaves the main thread's cleanup + // seeing isLockHeldLocally()===false 鈫?no exit notification, and unhides + // mid-turn. Subagents don't start CU sessions so this is a pure skip. + if (!toolUseContext.agentId) { + try { + const { cleanupComputerUseAfterTurn } = await import( + '../utils/computerUse/cleanup.js' + ) + await cleanupComputerUseAfterTurn(toolUseContext) + } catch { + // Failures are silent 鈥?this is dogfooding cleanup, not critical path + } + } + + try { + const blockingErrors = [] + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + if (!toolUseContext.agentId) { + ensureThreadGoalHookFromTranscript( + toolUseContext, + getSessionId(), + [...messagesForQuery, ...assistantMessages], + ) + } + + const generator = executeStopHooks( + permissionMode, + toolUseContext.abortController.signal, + undefined, + stopHookActive ?? false, + toolUseContext.agentId, + toolUseContext, + [...messagesForQuery, ...assistantMessages], + toolUseContext.agentType, + ) + + // Consume all progress messages and get blocking errors + let stopHookToolUseID = '' + let hookCount = 0 + let preventedContinuation = false + let stopReason = '' + let hasOutput = false + const hookErrors: string[] = [] + const hookInfos: StopHookInfo[] = [] + let goalCompleted = false + let goalContinuationReason: string | null = null + + // Goal hook's preventContinuation and blockingError arrive as separate + // generator yields 鈥?preventContinuation comes first, but blockingError.command + // (which identifies it as a goal hook) arrives later. Defer the + // preventContinuation decision until we've seen all results and can + // cross-reference with blockingError.command. + let pendingPreventContinuation: { + stopReason: string + toolUseID: string + } | null = null + // Track whether any blockingError came from a goal hook, so we can + // resolve the pending preventContinuation correctly after the loop. + let goalBlockingErrorSeen = false + + for await (const result of generator) { + if (result.message) { + yield result.message + // Track toolUseID from progress messages and count hooks + if (result.message.type === 'progress' && result.message.toolUseID) { + stopHookToolUseID = result.message.toolUseID + hookCount++ + // Extract hook command and prompt text from progress data + const progressData = result.message.data as HookProgress + if (progressData.command) { + hookInfos.push({ + command: progressData.command, + promptText: progressData.promptText, + }) + } + } + // Track errors and output from attachments + if (result.message.type === 'attachment') { + const attachment = result.message.attachment + if ( + 'hookEvent' in attachment && + (attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop') + ) { + if (attachment.type === 'hook_non_blocking_error') { + hookErrors.push( + attachment.stderr || `Exit code ${attachment.exitCode}`, + ) + // Non-blocking errors always have output + hasOutput = true + } else if (attachment.type === 'hook_error_during_execution') { + hookErrors.push(attachment.content) + hasOutput = true + } else if (attachment.type === 'hook_success') { + if (isGoalPromptHookCommand(attachment.command)) { + goalCompleted = true + } + // Check if successful hook produced any stdout/stderr + if ( + (attachment.stdout && attachment.stdout.trim()) || + (attachment.stderr && attachment.stderr.trim()) + ) { + hasOutput = true + } + } + // Extract per-hook duration for timing visibility. + // Hooks run in parallel; match by command + first unassigned entry. + if ('durationMs' in attachment && 'command' in attachment) { + const info = hookInfos.find( + i => + i.command === attachment.command && + i.durationMs === undefined, + ) + if (info) { + info.durationMs = attachment.durationMs + } + } + } + } + } + if (result.blockingError) { + const isGoalHook = isGoalPromptHookCommand(result.blockingError.command) + if (isGoalHook) { + goalContinuationReason ??= result.blockingError.blockingError + // If this blockingError is from a goal hook AND we have a pending + // preventContinuation, the goal hook's intent is block-and-continue + // (not prevent-and-stop). Mark it so we can resolve after the loop. + if (pendingPreventContinuation) { + goalBlockingErrorSeen = true + } + } + const userMessage = createUserMessage({ + content: getStopHookMessage(result.blockingError), + isMeta: true, // Hide from UI (shown in summary message instead) + }) + blockingErrors.push(userMessage) + yield userMessage + hasOutput = true + // Add to hookErrors so it appears in the summary + hookErrors.push(result.blockingError.blockingError) + } + // Check if hook wants to prevent continuation + if (result.preventContinuation) { + // If blockingError.command is already available in this same result, + // we can decide immediately using shouldLetGoalPromptHookContinue. + if (shouldLetGoalPromptHookContinue(result)) { + // Goal hook wants to block-and-continue 鈥?don't prevent. + // The blockingError (in this or a later yield) drives loop continuation. + } else if (result.blockingError?.command) { + // Has a blockingError.command but it's NOT a goal hook 鈫?prevent immediately + preventedContinuation = true + stopReason = result.stopReason || 'Stop hook prevented continuation' + // Create attachment to track the stopped continuation (for structured data) + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: stopReason, + hookName: 'Stop', + toolUseID: stopHookToolUseID, + hookEvent: 'Stop', + }) + } else { + // No blockingError yet 鈥?defer the decision. The goal hook's + // blockingError.command may arrive in a later yield, and we need + // it to distinguish goal-hook preventContinuation (block-and-continue) + // from regular preventContinuation (prevent-and-stop). + pendingPreventContinuation = { + stopReason: result.stopReason || 'Stop hook prevented continuation', + toolUseID: stopHookToolUseID, + } + } + } + + // Check if we were aborted during hook execution + if (toolUseContext.abortController.signal.aborted) { + logEvent('tengu_pre_stop_hooks_cancelled', { + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield createUserInterruptionMessage({ + toolUse: false, + }) + return { blockingErrors: [], preventContinuation: true } + } + } + + // Resolve any pending preventContinuation after collecting all results. + // If a goal hook's blockingError was seen, the preventContinuation was + // the goal hook's signal 鈥?it wants to block-and-continue, not stop. + // The blockingError will drive the query loop continuation via the + // blockingErrors return path. + if (pendingPreventContinuation && goalBlockingErrorSeen) { + // Don't set preventedContinuation 鈥?the blockingErrors will drive + // loop continuation in query.ts. + pendingPreventContinuation = null + } else if (pendingPreventContinuation) { + // No goal hook blockingError was found 鈫?this preventContinuation + // is from a regular hook 鈫?actually prevent and stop the session. + preventedContinuation = true + stopReason = pendingPreventContinuation.stopReason + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: stopReason, + hookName: 'Stop', + toolUseID: pendingPreventContinuation.toolUseID, + hookEvent: 'Stop', + }) + pendingPreventContinuation = null + } + + // Create summary system message if hooks ran + if (hookCount > 0) { + yield createStopHookSummaryMessage( + hookCount, + hookInfos, + hookErrors, + preventedContinuation, + stopReason, + hasOutput, + 'suggestion', + stopHookToolUseID, + ) + + // Send notification about errors (shown in verbose/transcript mode via ctrl+o) + if (hookErrors.length > 0) { + const expandShortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + toolUseContext.addNotification?.({ + key: 'stop-hook-error', + text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, + priority: 'immediate', + }) + } + } + + if (goalCompleted) { + yield createCommandInputMessage( + 'Goal marked complete.', + ) + } + + if (goalContinuationReason) { + yield createCommandInputMessage( + `${formatGoalContinuationStatusOutput(goalContinuationReason)}`, + ) + } + + if (preventedContinuation) { + return { blockingErrors: [], preventContinuation: true } + } + + // Collect blocking errors from stop hooks + if (blockingErrors.length > 0) { + return { blockingErrors, preventContinuation: false } + } + + // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate + if (isTeammate()) { + const teammateName = getAgentName() ?? '' + const teamName = getTeamName() ?? '' + const teammateBlockingErrors: Message[] = [] + let teammatePreventedContinuation = false + let teammateStopReason: string | undefined + // Each hook executor generates its own toolUseID 鈥?capture from progress + // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. + let teammateHookToolUseID = '' + + // Run TaskCompleted hooks for any in-progress tasks owned by this teammate + const taskListId = getTaskListId() + const tasks = await listTasks(taskListId) + const inProgressTasks = tasks.filter( + t => t.status === 'in_progress' && t.owner === teammateName, + ) + + for (const task of inProgressTasks) { + const taskCompletedGenerator = executeTaskCompletedHooks( + task.id, + task.subject, + task.description, + teammateName, + teamName, + permissionMode, + toolUseContext.abortController.signal, + undefined, + toolUseContext, + ) + + for await (const result of taskCompletedGenerator) { + if (result.message) { + if ( + result.message.type === 'progress' && + result.message.toolUseID + ) { + teammateHookToolUseID = result.message.toolUseID + } + yield result.message + } + if (result.blockingError) { + const userMessage = createUserMessage({ + content: getTaskCompletedHookMessage(result.blockingError), + isMeta: true, + }) + teammateBlockingErrors.push(userMessage) + yield userMessage + } + // Match Stop hook behavior: allow preventContinuation/stopReason + if (result.preventContinuation) { + teammatePreventedContinuation = true + teammateStopReason = + result.stopReason || 'TaskCompleted hook prevented continuation' + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: teammateStopReason, + hookName: 'TaskCompleted', + toolUseID: teammateHookToolUseID, + hookEvent: 'TaskCompleted', + }) + } + if (toolUseContext.abortController.signal.aborted) { + return { blockingErrors: [], preventContinuation: true } + } + } + } + + // Run TeammateIdle hooks + const teammateIdleGenerator = executeTeammateIdleHooks( + teammateName, + teamName, + permissionMode, + toolUseContext.abortController.signal, + ) + + for await (const result of teammateIdleGenerator) { + if (result.message) { + if (result.message.type === 'progress' && result.message.toolUseID) { + teammateHookToolUseID = result.message.toolUseID + } + yield result.message + } + if (result.blockingError) { + const userMessage = createUserMessage({ + content: getTeammateIdleHookMessage(result.blockingError), + isMeta: true, + }) + teammateBlockingErrors.push(userMessage) + yield userMessage + } + // Match Stop hook behavior: allow preventContinuation/stopReason + if (result.preventContinuation) { + teammatePreventedContinuation = true + teammateStopReason = + result.stopReason || 'TeammateIdle hook prevented continuation' + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: teammateStopReason, + hookName: 'TeammateIdle', + toolUseID: teammateHookToolUseID, + hookEvent: 'TeammateIdle', + }) + } + if (toolUseContext.abortController.signal.aborted) { + return { blockingErrors: [], preventContinuation: true } + } + } + + if (teammatePreventedContinuation) { + return { blockingErrors: [], preventContinuation: true } + } + + if (teammateBlockingErrors.length > 0) { + return { + blockingErrors: teammateBlockingErrors, + preventContinuation: false, + } + } + } + + return { blockingErrors: [], preventContinuation: false } + } catch (error) { + const durationMs = Date.now() - hookStartTime + logEvent('tengu_stop_hook_error', { + duration: durationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + // Yield a system message that is not visible to the model for the user + // to debug their hook. + yield createSystemMessage( + `Stop hook failed: ${errorMessage(error)}`, + 'warning', + ) + return { blockingErrors: [], preventContinuation: false } + } +} + + +======================================== +FILE: src_utils_hooks_hooksSettings.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksSettings.ts +LINES: 271 +========== +import { resolve } from 'path' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { getSessionId } from '../../bootstrap/state.js' +import type { AppState } from '../../state/AppState.js' +import type { EditableSettingSource } from '../settings/constants.js' +import { SOURCES } from '../settings/constants.js' +import { + getSettingsFilePathForSource, + getSettingsForSource, +} from '../settings/settings.js' +import type { HookCommand, HookMatcher } from '../settings/types.js' +import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' +import { getSessionHooks } from './sessionHooks.js' + +export type HookSource = + | EditableSettingSource + | 'policySettings' + | 'pluginHook' + | 'sessionHook' + | 'builtinHook' + +export interface IndividualHookConfig { + event: HookEvent + config: HookCommand + matcher?: string + source: HookSource + pluginName?: string +} + +/** + * Check if two hooks are equal (comparing only command/prompt content, not timeout) + */ +export function isHookEqual( + a: HookCommand | { type: 'function'; timeout?: number }, + b: HookCommand | { type: 'function'; timeout?: number }, +): boolean { + if (a.type !== b.type) return false + + // Use switch for exhaustive type checking + // Note: We only compare command/prompt content, not timeout + // `if` is part of identity: same command with different `if` conditions + // are distinct hooks (e.g., setup.sh if=Bash(git *) vs if=Bash(npm *)). + const sameIf = (x: { if?: string }, y: { if?: string }) => + (x.if ?? '') === (y.if ?? '') + switch (a.type) { + case 'command': + // shell is part of identity: same command string with different + // shells are distinct hooks. Default 'bash' so undefined === 'bash'. + return ( + b.type === 'command' && + a.command === b.command && + (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) && + sameIf(a, b) + ) + case 'prompt': + return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b) + case 'agent': + return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b) + case 'http': + return b.type === 'http' && a.url === b.url && sameIf(a, b) + case 'function': + // Function hooks can't be compared (no stable identifier) + return false + } +} + +/** Get the display text for a hook */ +export function getHookDisplayText( + hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string }, +): string { + // Return custom status message if provided + if ('statusMessage' in hook && hook.statusMessage) { + return hook.statusMessage + } + + switch (hook.type) { + case 'command': + return hook.command + case 'prompt': + return hook.prompt + case 'agent': + return hook.prompt + case 'http': + return hook.url + case 'callback': + return 'callback' + case 'function': + return 'function' + } +} + +export function getAllHooks(appState: AppState): IndividualHookConfig[] { + const hooks: IndividualHookConfig[] = [] + + // Check if restricted to managed hooks only + const policySettings = getSettingsForSource('policySettings') + const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true + + // If allowManagedHooksOnly is set, don't show any hooks in the UI + // (user/project/local are blocked, and managed hooks are intentionally hidden) + if (!restrictedToManagedOnly) { + // Get hooks from all editable sources + const sources = [ + 'userSettings', + 'projectSettings', + 'localSettings', + ] as EditableSettingSource[] + + // Track which settings files we've already processed to avoid duplicates + // (e.g., when running from home directory, userSettings and projectSettings + // both resolve to ~/.claude/settings.json) + const seenFiles = new Set() + + for (const source of sources) { + const filePath = getSettingsFilePathForSource(source) + if (filePath) { + const resolvedPath = resolve(filePath) + if (seenFiles.has(resolvedPath)) { + continue + } + seenFiles.add(resolvedPath) + } + + const sourceSettings = getSettingsForSource(source) + if (!sourceSettings?.hooks) { + continue + } + + for (const [event, matchers] of Object.entries(sourceSettings.hooks)) { + for (const matcher of matchers as HookMatcher[]) { + for (const hookCommand of matcher.hooks) { + hooks.push({ + event: event as HookEvent, + config: hookCommand, + matcher: matcher.matcher, + source, + }) + } + } + } + } + } + + // Get session hooks + const sessionId = getSessionId() + const sessionHooks = getSessionHooks(appState, sessionId) + for (const [event, matchers] of sessionHooks.entries()) { + for (const matcher of matchers) { + for (const hookCommand of matcher.hooks) { + hooks.push({ + event, + config: hookCommand, + matcher: matcher.matcher, + source: 'sessionHook', + }) + } + } + } + + return hooks +} + +export function getHooksForEvent( + appState: AppState, + event: HookEvent, +): IndividualHookConfig[] { + return getAllHooks(appState).filter(hook => hook.event === event) +} + +export function hookSourceDescriptionDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User settings (~/.claude/settings.json)' + case 'projectSettings': + return 'Project settings (.claude/settings.json)' + case 'localSettings': + return 'Local settings (.claude/settings.local.json)' + case 'pluginHook': + // TODO: Get the actual plugin hook file paths instead of using glob pattern + // We should capture the specific plugin paths during hook registration and display them here + // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" + return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' + case 'sessionHook': + return 'Session hooks (in-memory, temporary)' + case 'builtinHook': + return 'Built-in hooks (registered internally by Claude Code)' + default: + return source as string + } +} + +export function hookSourceHeaderDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User Settings' + case 'projectSettings': + return 'Project Settings' + case 'localSettings': + return 'Local Settings' + case 'pluginHook': + return 'Plugin Hooks' + case 'sessionHook': + return 'Session Hooks' + case 'builtinHook': + return 'Built-in Hooks' + default: + return source as string + } +} + +export function hookSourceInlineDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User' + case 'projectSettings': + return 'Project' + case 'localSettings': + return 'Local' + case 'pluginHook': + return 'Plugin' + case 'sessionHook': + return 'Session' + case 'builtinHook': + return 'Built-in' + default: + return source as string + } +} + +export function sortMatchersByPriority( + matchers: string[], + hooksByEventAndMatcher: Record< + string, + Record + >, + selectedEvent: HookEvent, +): string[] { + // Create a priority map based on SOURCES order (lower index = higher priority) + const sourcePriority = SOURCES.reduce( + (acc, source, index) => { + acc[source] = index + return acc + }, + {} as Record, + ) + + return [...matchers].sort((a, b) => { + const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || [] + const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || [] + + const aSources = Array.from(new Set(aHooks.map(h => h.source))) + const bSources = Array.from(new Set(bHooks.map(h => h.source))) + + // Sort by highest priority source first (lowest priority number) + // Plugin hooks get lowest priority (highest number) + const getSourcePriority = (source: HookSource) => + source === 'pluginHook' || source === 'builtinHook' + ? 999 + : sourcePriority[source as EditableSettingSource] + + const aHighestPriority = Math.min(...aSources.map(getSourcePriority)) + const bHighestPriority = Math.min(...bSources.map(getSourcePriority)) + + if (aHighestPriority !== bHighestPriority) { + return aHighestPriority - bHighestPriority + } + + // If same priority, sort by matcher name + return a.localeCompare(b) + }) +} + + +======================================== +FILE: src_utils_hooks_registerFrontmatterHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerFrontmatterHooks.ts +LINES: 67 +========== +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import { logForDebugging } from '../debug.js' +import type { HooksSettings } from '../settings/types.js' +import { addSessionHook } from './sessionHooks.js' + +/** + * Register hooks from frontmatter (agent or skill) into session-scoped hooks. + * These hooks will be active for the duration of the session/agent and cleaned up + * when the session/agent ends. + * + * @param setAppState Function to update app state + * @param sessionId Session ID to scope the hooks (agent ID for agents, session ID for skills) + * @param hooks The hooks settings from frontmatter + * @param sourceName Human-readable source name for logging (e.g., "agent 'my-agent'") + * @param isAgent If true, converts Stop hooks to SubagentStop (since subagents trigger SubagentStop, not Stop) + */ +export function registerFrontmatterHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + hooks: HooksSettings, + sourceName: string, + isAgent: boolean = false, +): void { + if (!hooks || Object.keys(hooks).length === 0) { + return + } + + let hookCount = 0 + + for (const event of HOOK_EVENTS) { + const matchers = hooks[event] + if (!matchers || matchers.length === 0) { + continue + } + + // For agents, convert Stop hooks to SubagentStop since that's what fires when an agent completes + // (executeStopHooks uses SubagentStop when called with an agentId) + let targetEvent: HookEvent = event + if (isAgent && event === 'Stop') { + targetEvent = 'SubagentStop' + logForDebugging( + `Converting Stop hook to SubagentStop for ${sourceName} (subagents trigger SubagentStop)`, + ) + } + + for (const matcherConfig of matchers) { + const matcher = matcherConfig.matcher ?? '' + const hooksArray = matcherConfig.hooks + + if (!hooksArray || hooksArray.length === 0) { + continue + } + + for (const hook of hooksArray) { + addSessionHook(setAppState, sessionId, targetEvent, matcher, hook) + hookCount++ + } + } + } + + if (hookCount > 0) { + logForDebugging( + `Registered ${hookCount} frontmatter hook(s) from ${sourceName} for session ${sessionId}`, + ) + } +} + + +======================================== +FILE: src_utils_hooks_registerSkillHooks.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerSkillHooks.ts +LINES: 64 +========== +import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import { logForDebugging } from '../debug.js' +import type { HooksSettings } from '../settings/types.js' +import { addSessionHook, removeSessionHook } from './sessionHooks.js' + +/** + * Registers hooks from a skill's frontmatter as session hooks. + * + * Hooks are registered as session-scoped hooks that persist for the duration + * of the session. If a hook has `once: true`, it will be automatically removed + * after its first successful execution. + * + * @param setAppState - Function to update the app state + * @param sessionId - The current session ID + * @param hooks - The hooks settings from the skill's frontmatter + * @param skillName - The name of the skill (for logging) + * @param skillRoot - The base directory of the skill (for CLAUDE_PLUGIN_ROOT env var) + */ +export function registerSkillHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + hooks: HooksSettings, + skillName: string, + skillRoot?: string, +): void { + let registeredCount = 0 + + for (const eventName of HOOK_EVENTS) { + const matchers = hooks[eventName] + if (!matchers) continue + + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + // For once: true hooks, use onHookSuccess callback to remove after execution + const onHookSuccess = hook.once + ? () => { + logForDebugging( + `Removing one-shot hook for event ${eventName} in skill '${skillName}'`, + ) + removeSessionHook(setAppState, sessionId, eventName, hook) + } + : undefined + + addSessionHook( + setAppState, + sessionId, + eventName, + matcher.matcher || '', + hook, + onHookSuccess, + skillRoot, + ) + registeredCount++ + } + } + } + + if (registeredCount > 0) { + logForDebugging( + `Registered ${registeredCount} hooks from skill '${skillName}'`, + ) + } +} + + +======================================== +FILE: src_utils_hooks_apiQueryHookHelper.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\apiQueryHookHelper.ts +LINES: 141 +========== +import { randomUUID } from 'crypto' +import type { QuerySource } from '../../constants/querySource.js' +import { queryModelWithoutStreaming } from '../../services/api/claude.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../../utils/abortController.js' +import { logError } from '../../utils/log.js' +import { toError } from '../errors.js' +import { extractTextContent } from '../messages.js' +import { asSystemPrompt } from '../systemPromptType.js' +import type { REPLHookContext } from './postSamplingHooks.js' + +export type ApiQueryHookContext = REPLHookContext & { + queryMessageCount?: number +} + +export type ApiQueryHookConfig = { + name: QuerySource + shouldRun: (context: ApiQueryHookContext) => Promise + + // Build the complete message list to send to the API + buildMessages: (context: ApiQueryHookContext) => Message[] + + // Optional: override system prompt (defaults to context.systemPrompt) + systemPrompt?: string + + // Optional: whether to use tools from context (defaults to true) + // Set to false to pass empty tools array + useTools?: boolean + + parseResponse: (content: string, context: ApiQueryHookContext) => TResult + logResult: ( + result: ApiQueryResult, + context: ApiQueryHookContext, + ) => void + // Must be a function to ensure lazy loading (config is accessed before allowed) + // Receives context so callers can inherit the main loop model if desired. + getModel: (context: ApiQueryHookContext) => string +} + +export type ApiQueryResult = + | { + type: 'success' + queryName: string + result: TResult + messageId: string + model: string + uuid: string + } + | { + type: 'error' + queryName: string + error: Error + uuid: string + } + +export function createApiQueryHook( + config: ApiQueryHookConfig, +) { + return async (context: ApiQueryHookContext): Promise => { + try { + const shouldRun = await config.shouldRun(context) + if (!shouldRun) { + return + } + + const uuid = randomUUID() + + // Build messages using the config's buildMessages function + const messages = config.buildMessages(context) + context.queryMessageCount = messages.length + + // Use config's system prompt if provided, otherwise use context's + const systemPrompt = config.systemPrompt + ? asSystemPrompt([config.systemPrompt]) + : context.systemPrompt + + // Use config's tools preference (defaults to true = use context tools) + const useTools = config.useTools ?? true + const tools = useTools ? context.toolUseContext.options.tools : [] + + // Get model (lazy loaded) + const model = config.getModel(context) + + // Make API call + const response = await queryModelWithoutStreaming({ + messages, + systemPrompt, + thinkingConfig: { type: 'disabled' as const }, + tools, + signal: createAbortController().signal, + options: { + getToolPermissionContext: async () => { + const appState = context.toolUseContext.getAppState() + return appState.toolPermissionContext + }, + model, + toolChoice: undefined, + isNonInteractiveSession: + context.toolUseContext.options.isNonInteractiveSession, + hasAppendSystemPrompt: + !!context.toolUseContext.options.appendSystemPrompt, + temperatureOverride: 0, + agents: context.toolUseContext.options.agentDefinitions.activeAgents, + querySource: config.name, + mcpTools: [], + agentId: context.toolUseContext.agentId, + }, + }) + + // Parse response + const content = extractTextContent(response.message.content).trim() + + try { + const result = config.parseResponse(content, context) + config.logResult( + { + type: 'success', + queryName: config.name, + result, + messageId: response.message.id, + model, + uuid, + }, + context, + ) + } catch (error) { + config.logResult( + { + type: 'error', + queryName: config.name, + error: error as Error, + uuid, + }, + context, + ) + } + } catch (error) { + logError(toError(error)) + } + } +} + + +======================================== +FILE: src_utils_hooks_fileChangedWatcher.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\fileChangedWatcher.ts +LINES: 191 +========== +import chokidar, { type FSWatcher } from 'chokidar' +import { isAbsolute, join } from 'path' +import { registerCleanup } from '../cleanupRegistry.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import { + executeCwdChangedHooks, + executeFileChangedHooks, + type HookOutsideReplResult, +} from '../hooks.js' +import { clearCwdEnvFiles } from '../sessionEnvironment.js' +import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js' + +let watcher: FSWatcher | null = null +let currentCwd: string +let dynamicWatchPaths: string[] = [] +let dynamicWatchPathsSorted: string[] = [] +let initialized = false +let hasEnvHooks = false +let notifyCallback: ((text: string, isError: boolean) => void) | null = null + +export function setEnvHookNotifier( + cb: ((text: string, isError: boolean) => void) | null, +): void { + notifyCallback = cb +} + +export function initializeFileChangedWatcher(cwd: string): void { + if (initialized) return + initialized = true + currentCwd = cwd + + const config = getHooksConfigFromSnapshot() + hasEnvHooks = + (config?.CwdChanged?.length ?? 0) > 0 || + (config?.FileChanged?.length ?? 0) > 0 + + if (hasEnvHooks) { + registerCleanup(async () => dispose()) + } + + const paths = resolveWatchPaths(config) + if (paths.length === 0) return + + startWatching(paths) +} + +function resolveWatchPaths( + config?: ReturnType, +): string[] { + const matchers = (config ?? getHooksConfigFromSnapshot())?.FileChanged ?? [] + + // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env") + const staticPaths: string[] = [] + for (const m of matchers) { + if (!m.matcher) continue + for (const name of m.matcher.split('|').map(s => s.trim())) { + if (!name) continue + staticPaths.push(isAbsolute(name) ? name : join(currentCwd, name)) + } + } + + // Combine static matcher paths with dynamic paths from hook output + return [...new Set([...staticPaths, ...dynamicWatchPaths])] +} + +function startWatching(paths: string[]): void { + logForDebugging(`FileChanged: watching ${paths.length} paths`) + watcher = chokidar.watch(paths, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 200 }, + ignorePermissionErrors: true, + }) + watcher.on('change', p => handleFileEvent(p, 'change')) + watcher.on('add', p => handleFileEvent(p, 'add')) + watcher.on('unlink', p => handleFileEvent(p, 'unlink')) +} + +function handleFileEvent( + path: string, + event: 'change' | 'add' | 'unlink', +): void { + logForDebugging(`FileChanged: ${event} ${path}`) + void executeFileChangedHooks(path, event) + .then(({ results, watchPaths, systemMessages }) => { + if (watchPaths.length > 0) { + updateWatchPaths(watchPaths) + } + for (const msg of systemMessages) { + notifyCallback?.(msg, false) + } + for (const r of results) { + if (!r.succeeded && r.output) { + notifyCallback?.(r.output, true) + } + } + }) + .catch(e => { + const msg = errorMessage(e) + logForDebugging(`FileChanged hook failed: ${msg}`, { + level: 'error', + }) + notifyCallback?.(msg, true) + }) +} + +export function updateWatchPaths(paths: string[]): void { + if (!initialized) return + const sorted = paths.slice().sort() + if ( + sorted.length === dynamicWatchPathsSorted.length && + sorted.every((p, i) => p === dynamicWatchPathsSorted[i]) + ) { + return + } + dynamicWatchPaths = paths + dynamicWatchPathsSorted = sorted + restartWatching() +} + +function restartWatching(): void { + if (watcher) { + void watcher.close() + watcher = null + } + const paths = resolveWatchPaths() + if (paths.length > 0) { + startWatching(paths) + } +} + +export async function onCwdChangedForHooks( + oldCwd: string, + newCwd: string, +): Promise { + if (oldCwd === newCwd) return + + // Re-evaluate from the current snapshot so mid-session hook changes are picked up + const config = getHooksConfigFromSnapshot() + const currentHasEnvHooks = + (config?.CwdChanged?.length ?? 0) > 0 || + (config?.FileChanged?.length ?? 0) > 0 + if (!currentHasEnvHooks) return + currentCwd = newCwd + + await clearCwdEnvFiles() + const hookResult = await executeCwdChangedHooks(oldCwd, newCwd).catch(e => { + const msg = errorMessage(e) + logForDebugging(`CwdChanged hook failed: ${msg}`, { + level: 'error', + }) + notifyCallback?.(msg, true) + return { + results: [] as HookOutsideReplResult[], + watchPaths: [] as string[], + systemMessages: [] as string[], + } + }) + dynamicWatchPaths = hookResult.watchPaths + dynamicWatchPathsSorted = hookResult.watchPaths.slice().sort() + for (const msg of hookResult.systemMessages) { + notifyCallback?.(msg, false) + } + for (const r of hookResult.results) { + if (!r.succeeded && r.output) { + notifyCallback?.(r.output, true) + } + } + + // Re-resolve matcher paths against the new cwd + if (initialized) { + restartWatching() + } +} + +function dispose(): void { + if (watcher) { + void watcher.close() + watcher = null + } + dynamicWatchPaths = [] + dynamicWatchPathsSorted = [] + initialized = false + hasEnvHooks = false + notifyCallback = null +} + +export function resetFileChangedWatcherForTesting(): void { + dispose() +} + + +======================================== +FILE: src_query_stopHooks.test.ts +======================================== +FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.test.ts +LINES: 49 +========== +import { describe, expect, test } from 'bun:test' +import { + formatGoalContinuationStatusOutput, + shouldLetGoalPromptHookContinue, +} from './stopHooks.js' + +describe('stop hook goal continuation', () => { + test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => { + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: true, + blockingError: { + blockingError: 'Prompt hook condition was not met: keep working', + command: '\nship the feature', + }, + }), + ).toBe(true) + }) + + test('preserves prevent-continuation semantics for non-goal hooks', () => { + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: true, + blockingError: { + blockingError: 'Prompt hook condition was not met: stop', + command: 'ordinary prompt hook', + }, + }), + ).toBe(false) + + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: false, + blockingError: { + blockingError: 'Prompt hook condition was not met: keep working', + command: '\nship the feature', + }, + }), + ).toBe(false) + }) + + test('formats goal continuation status output for visible transcript separators', () => { + expect( + formatGoalContinuationStatusOutput( + 'Prompt hook condition was not met: finish & verify', + ), + ).toBe('Goal continuing: finish release verify') + }) +}) diff --git a/hook-dump/src_commands_hooks_hooks.tsx b/hook-dump/src_commands_hooks_hooks.tsx new file mode 100644 index 0000000000..dc38bbadee --- /dev/null +++ b/hook-dump/src_commands_hooks_hooks.tsx @@ -0,0 +1,16 @@ +FILE: E:\Yuanban\cc-haha-src\src\commands\hooks\hooks.tsx +LINES: 13 +========== +import * as React from 'react'; +import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { getTools } from '../../tools.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +export const call: LocalJSXCommandCall = async (onDone, context) => { + logEvent('tengu_hooks_command', {}); + const appState = context.getAppState(); + const permissionContext = appState.toolPermissionContext; + const toolNames = getTools(permissionContext).map(tool => tool.name); + return ; +}; +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119 diff --git a/hook-dump/src_costHook.ts b/hook-dump/src_costHook.ts new file mode 100644 index 0000000000..c4bed8f7d9 --- /dev/null +++ b/hook-dump/src_costHook.ts @@ -0,0 +1,25 @@ +FILE: E:\Yuanban\cc-haha-src\src\costHook.ts +LINES: 22 +========== +import { useEffect } from 'react' +import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js' +import { hasConsoleBillingAccess } from './utils/billing.js' +import type { FpsMetrics } from './utils/fpsTracker.js' + +export function useCostSummary( + getFpsMetrics?: () => FpsMetrics | undefined, +): void { + useEffect(() => { + const f = () => { + if (hasConsoleBillingAccess()) { + process.stdout.write('\n' + formatTotalCost() + '\n') + } + + saveCurrentSessionCosts(getFpsMetrics?.()) + } + process.on('exit', f) + return () => { + process.off('exit', f) + } + }, []) +} diff --git a/hook-dump/src_hooks_useDeferredHookMessages.ts b/hook-dump/src_hooks_useDeferredHookMessages.ts new file mode 100644 index 0000000000..6acc6e9d94 --- /dev/null +++ b/hook-dump/src_hooks_useDeferredHookMessages.ts @@ -0,0 +1,49 @@ +FILE: E:\Yuanban\cc-haha-src\src\hooks\useDeferredHookMessages.ts +LINES: 46 +========== +import { useCallback, useEffect, useRef } from 'react' +import type { HookResultMessage, Message } from '../types/message.js' + +/** + * Manages deferred SessionStart hook messages so the REPL can render + * immediately instead of blocking on hook execution (~500ms). + * + * Hook messages are injected asynchronously when the promise resolves. + * Returns a callback that onSubmit should call before the first API + * request to ensure the model always sees hook context. + */ +export function useDeferredHookMessages( + pendingHookMessages: Promise | undefined, + setMessages: (action: React.SetStateAction) => void, +): () => Promise { + const pendingRef = useRef(pendingHookMessages ?? null) + const resolvedRef = useRef(!pendingHookMessages) + + useEffect(() => { + const promise = pendingRef.current + if (!promise) return + let cancelled = false + promise.then(msgs => { + if (cancelled) return + resolvedRef.current = true + pendingRef.current = null + if (msgs.length > 0) { + setMessages(prev => [...msgs, ...prev]) + } + }) + return () => { + cancelled = true + } + }, [setMessages]) + + return useCallback(async () => { + if (resolvedRef.current || !pendingRef.current) return + const msgs = await pendingRef.current + if (resolvedRef.current) return + resolvedRef.current = true + pendingRef.current = null + if (msgs.length > 0) { + setMessages(prev => [...msgs, ...prev]) + } + }, [setMessages]) +} diff --git a/hook-dump/src_query_stopHooks.test.ts b/hook-dump/src_query_stopHooks.test.ts new file mode 100644 index 0000000000..277393fbb4 --- /dev/null +++ b/hook-dump/src_query_stopHooks.test.ts @@ -0,0 +1,52 @@ +FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.test.ts +LINES: 49 +========== +import { describe, expect, test } from 'bun:test' +import { + formatGoalContinuationStatusOutput, + shouldLetGoalPromptHookContinue, +} from './stopHooks.js' + +describe('stop hook goal continuation', () => { + test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => { + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: true, + blockingError: { + blockingError: 'Prompt hook condition was not met: keep working', + command: '\nship the feature', + }, + }), + ).toBe(true) + }) + + test('preserves prevent-continuation semantics for non-goal hooks', () => { + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: true, + blockingError: { + blockingError: 'Prompt hook condition was not met: stop', + command: 'ordinary prompt hook', + }, + }), + ).toBe(false) + + expect( + shouldLetGoalPromptHookContinue({ + preventContinuation: false, + blockingError: { + blockingError: 'Prompt hook condition was not met: keep working', + command: '\nship the feature', + }, + }), + ).toBe(false) + }) + + test('formats goal continuation status output for visible transcript separators', () => { + expect( + formatGoalContinuationStatusOutput( + 'Prompt hook condition was not met: finish & verify', + ), + ).toBe('Goal continuing: finish release verify') + }) +}) diff --git a/hook-dump/src_query_stopHooks.ts b/hook-dump/src_query_stopHooks.ts new file mode 100644 index 0000000000..691997a320 --- /dev/null +++ b/hook-dump/src_query_stopHooks.ts @@ -0,0 +1,594 @@ +FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.ts +LINES: 591 +========== +import { feature } from 'bun:bundle' +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' +import { getSessionId } from '../bootstrap/state.js' +import { isExtractModeActive } from '../memdir/paths.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../services/analytics/index.js' +import type { ToolUseContext } from '../Tool.js' +import { + ensureThreadGoalHookFromTranscript, + isGoalPromptHookCommand, +} from '../goals/goalState.js' +import type { HookResult } from '../types/hooks.js' +import type { HookProgress } from '../types/hooks.js' +import type { + AssistantMessage, + Message, + RequestStartEvent, + StopHookInfo, + StreamEvent, + TombstoneMessage, + ToolUseSummaryMessage, +} from '../types/message.js' +import { createAttachmentMessage } from '../utils/attachments.js' +import { logForDebugging } from '../utils/debug.js' +import { errorMessage } from '../utils/errors.js' +import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' +import { + executeStopHooks, + executeTaskCompletedHooks, + executeTeammateIdleHooks, + getStopHookMessage, + getTaskCompletedHookMessage, + getTeammateIdleHookMessage, +} from '../utils/hooks.js' +import { + createStopHookSummaryMessage, + createCommandInputMessage, + createSystemMessage, + createUserInterruptionMessage, + createUserMessage, +} from '../utils/messages.js' +import type { SystemPrompt } from '../utils/systemPromptType.js' +import { getTaskListId, listTasks } from '../utils/tasks.js' +import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js' + +/* eslint-disable @typescript-eslint/no-require-imports */ +const extractMemoriesModule = feature('EXTRACT_MEMORIES') + ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) + : null +const jobClassifierModule = feature('TEMPLATES') + ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) + : null + +/* eslint-enable @typescript-eslint/no-require-imports */ + +import type { QuerySource } from '../constants/querySource.js' +import { executeAutoDream } from '../services/autoDream/autoDream.js' +import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' +import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' +import { + createCacheSafeParams, + saveCacheSafeParams, +} from '../utils/forkedAgent.js' + +type StopHookResult = { + blockingErrors: Message[] + preventContinuation: boolean +} + +export function shouldLetGoalPromptHookContinue( + result: Pick, +): boolean { + return Boolean( + result.preventContinuation && + isGoalPromptHookCommand(result.blockingError?.command), + ) +} + +export function formatGoalContinuationStatusOutput(reason: string): string { + const normalizedReason = reason + .replace(/^Prompt hook condition was not met:\s*/i, '') + .replace(/[<>&]/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 240) + + return normalizedReason + ? `Goal continuing: ${normalizedReason}` + : 'Goal continuing: more work is required' +} + +export async function* handleStopHooks( + messagesForQuery: Message[], + assistantMessages: AssistantMessage[], + systemPrompt: SystemPrompt, + userContext: { [k: string]: string }, + systemContext: { [k: string]: string }, + toolUseContext: ToolUseContext, + querySource: QuerySource, + stopHookActive?: boolean, +): AsyncGenerator< + | StreamEvent + | RequestStartEvent + | Message + | TombstoneMessage + | ToolUseSummaryMessage, + StopHookResult +> { + const hookStartTime = Date.now() + + const stopHookContext: REPLHookContext = { + messages: [...messagesForQuery, ...assistantMessages], + systemPrompt, + userContext, + systemContext, + toolUseContext, + querySource, + } + // Only save params for main session queries 鈥?subagents must not overwrite. + // Outside the prompt-suggestion gate: the REPL /btw command and the + // side_question SDK control_request both read this snapshot, and neither + // depends on prompt suggestions being enabled. + if (querySource === 'repl_main_thread' || querySource === 'sdk') { + saveCacheSafeParams(createCacheSafeParams(stopHookContext)) + } + + // Template job classification: when running as a dispatched job, classify + // state after each turn. Gate on repl_main_thread so background forks + // (extract-memories, auto-dream) don't pollute the timeline with their own + // assistant messages. Await the classifier so state.json is written before + // the turn returns 鈥?otherwise `claude list` shows stale state for the gap. + // Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the + // require()-gated jobs/ import pattern above; spawn.test.ts asserts the + // string matches. + if ( + feature('TEMPLATES') && + process.env.CLAUDE_JOB_DIR && + querySource.startsWith('repl_main_thread') && + !toolUseContext.agentId + ) { + // Full turn history 鈥?assistantMessages resets each queryLoop iteration, + // so tool calls from earlier iterations (Agent spawn, then summary) need + // messagesForQuery to be visible in the tool-call summary. + const turnAssistantMessages = stopHookContext.messages.filter( + (m): m is AssistantMessage => m.type === 'assistant', + ) + const p = jobClassifierModule! + .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) + .catch(err => { + logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { + level: 'error', + }) + }) + await Promise.race([ + p, + // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit + new Promise(r => setTimeout(r, 60_000).unref()), + ]) + } + // --bare / SIMPLE: skip background bookkeeping (prompt suggestion, + // memory extraction, auto-dream). Scripted -p calls don't want auto-memory + // or forked agents contending for resources during shutdown. + if (!isBareMode()) { + // Inline env check for dead code elimination in external builds + if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { + void executePromptSuggestion(stopHookContext) + } + if ( + feature('EXTRACT_MEMORIES') && + !toolUseContext.agentId && + isExtractModeActive() + ) { + // Fire-and-forget in both interactive and non-interactive. For -p/SDK, + // print.ts drains the in-flight promise after flushing the response + // but before gracefulShutdownSync (see drainPendingExtraction). + void extractMemoriesModule!.executeExtractMemories( + stopHookContext, + toolUseContext.appendSystemMessage, + ) + } + if (!toolUseContext.agentId) { + void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) + } + } + + // chicago MCP: auto-unhide + lock release at turn end. + // Main thread only 鈥?the CU lock is a process-wide module-level variable, + // so a subagent's stopHooks releasing it leaves the main thread's cleanup + // seeing isLockHeldLocally()===false 鈫?no exit notification, and unhides + // mid-turn. Subagents don't start CU sessions so this is a pure skip. + if (!toolUseContext.agentId) { + try { + const { cleanupComputerUseAfterTurn } = await import( + '../utils/computerUse/cleanup.js' + ) + await cleanupComputerUseAfterTurn(toolUseContext) + } catch { + // Failures are silent 鈥?this is dogfooding cleanup, not critical path + } + } + + try { + const blockingErrors = [] + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + if (!toolUseContext.agentId) { + ensureThreadGoalHookFromTranscript( + toolUseContext, + getSessionId(), + [...messagesForQuery, ...assistantMessages], + ) + } + + const generator = executeStopHooks( + permissionMode, + toolUseContext.abortController.signal, + undefined, + stopHookActive ?? false, + toolUseContext.agentId, + toolUseContext, + [...messagesForQuery, ...assistantMessages], + toolUseContext.agentType, + ) + + // Consume all progress messages and get blocking errors + let stopHookToolUseID = '' + let hookCount = 0 + let preventedContinuation = false + let stopReason = '' + let hasOutput = false + const hookErrors: string[] = [] + const hookInfos: StopHookInfo[] = [] + let goalCompleted = false + let goalContinuationReason: string | null = null + + // Goal hook's preventContinuation and blockingError arrive as separate + // generator yields 鈥?preventContinuation comes first, but blockingError.command + // (which identifies it as a goal hook) arrives later. Defer the + // preventContinuation decision until we've seen all results and can + // cross-reference with blockingError.command. + let pendingPreventContinuation: { + stopReason: string + toolUseID: string + } | null = null + // Track whether any blockingError came from a goal hook, so we can + // resolve the pending preventContinuation correctly after the loop. + let goalBlockingErrorSeen = false + + for await (const result of generator) { + if (result.message) { + yield result.message + // Track toolUseID from progress messages and count hooks + if (result.message.type === 'progress' && result.message.toolUseID) { + stopHookToolUseID = result.message.toolUseID + hookCount++ + // Extract hook command and prompt text from progress data + const progressData = result.message.data as HookProgress + if (progressData.command) { + hookInfos.push({ + command: progressData.command, + promptText: progressData.promptText, + }) + } + } + // Track errors and output from attachments + if (result.message.type === 'attachment') { + const attachment = result.message.attachment + if ( + 'hookEvent' in attachment && + (attachment.hookEvent === 'Stop' || + attachment.hookEvent === 'SubagentStop') + ) { + if (attachment.type === 'hook_non_blocking_error') { + hookErrors.push( + attachment.stderr || `Exit code ${attachment.exitCode}`, + ) + // Non-blocking errors always have output + hasOutput = true + } else if (attachment.type === 'hook_error_during_execution') { + hookErrors.push(attachment.content) + hasOutput = true + } else if (attachment.type === 'hook_success') { + if (isGoalPromptHookCommand(attachment.command)) { + goalCompleted = true + } + // Check if successful hook produced any stdout/stderr + if ( + (attachment.stdout && attachment.stdout.trim()) || + (attachment.stderr && attachment.stderr.trim()) + ) { + hasOutput = true + } + } + // Extract per-hook duration for timing visibility. + // Hooks run in parallel; match by command + first unassigned entry. + if ('durationMs' in attachment && 'command' in attachment) { + const info = hookInfos.find( + i => + i.command === attachment.command && + i.durationMs === undefined, + ) + if (info) { + info.durationMs = attachment.durationMs + } + } + } + } + } + if (result.blockingError) { + const isGoalHook = isGoalPromptHookCommand(result.blockingError.command) + if (isGoalHook) { + goalContinuationReason ??= result.blockingError.blockingError + // If this blockingError is from a goal hook AND we have a pending + // preventContinuation, the goal hook's intent is block-and-continue + // (not prevent-and-stop). Mark it so we can resolve after the loop. + if (pendingPreventContinuation) { + goalBlockingErrorSeen = true + } + } + const userMessage = createUserMessage({ + content: getStopHookMessage(result.blockingError), + isMeta: true, // Hide from UI (shown in summary message instead) + }) + blockingErrors.push(userMessage) + yield userMessage + hasOutput = true + // Add to hookErrors so it appears in the summary + hookErrors.push(result.blockingError.blockingError) + } + // Check if hook wants to prevent continuation + if (result.preventContinuation) { + // If blockingError.command is already available in this same result, + // we can decide immediately using shouldLetGoalPromptHookContinue. + if (shouldLetGoalPromptHookContinue(result)) { + // Goal hook wants to block-and-continue 鈥?don't prevent. + // The blockingError (in this or a later yield) drives loop continuation. + } else if (result.blockingError?.command) { + // Has a blockingError.command but it's NOT a goal hook 鈫?prevent immediately + preventedContinuation = true + stopReason = result.stopReason || 'Stop hook prevented continuation' + // Create attachment to track the stopped continuation (for structured data) + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: stopReason, + hookName: 'Stop', + toolUseID: stopHookToolUseID, + hookEvent: 'Stop', + }) + } else { + // No blockingError yet 鈥?defer the decision. The goal hook's + // blockingError.command may arrive in a later yield, and we need + // it to distinguish goal-hook preventContinuation (block-and-continue) + // from regular preventContinuation (prevent-and-stop). + pendingPreventContinuation = { + stopReason: result.stopReason || 'Stop hook prevented continuation', + toolUseID: stopHookToolUseID, + } + } + } + + // Check if we were aborted during hook execution + if (toolUseContext.abortController.signal.aborted) { + logEvent('tengu_pre_stop_hooks_cancelled', { + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield createUserInterruptionMessage({ + toolUse: false, + }) + return { blockingErrors: [], preventContinuation: true } + } + } + + // Resolve any pending preventContinuation after collecting all results. + // If a goal hook's blockingError was seen, the preventContinuation was + // the goal hook's signal 鈥?it wants to block-and-continue, not stop. + // The blockingError will drive the query loop continuation via the + // blockingErrors return path. + if (pendingPreventContinuation && goalBlockingErrorSeen) { + // Don't set preventedContinuation 鈥?the blockingErrors will drive + // loop continuation in query.ts. + pendingPreventContinuation = null + } else if (pendingPreventContinuation) { + // No goal hook blockingError was found 鈫?this preventContinuation + // is from a regular hook 鈫?actually prevent and stop the session. + preventedContinuation = true + stopReason = pendingPreventContinuation.stopReason + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: stopReason, + hookName: 'Stop', + toolUseID: pendingPreventContinuation.toolUseID, + hookEvent: 'Stop', + }) + pendingPreventContinuation = null + } + + // Create summary system message if hooks ran + if (hookCount > 0) { + yield createStopHookSummaryMessage( + hookCount, + hookInfos, + hookErrors, + preventedContinuation, + stopReason, + hasOutput, + 'suggestion', + stopHookToolUseID, + ) + + // Send notification about errors (shown in verbose/transcript mode via ctrl+o) + if (hookErrors.length > 0) { + const expandShortcut = getShortcutDisplay( + 'app:toggleTranscript', + 'Global', + 'ctrl+o', + ) + toolUseContext.addNotification?.({ + key: 'stop-hook-error', + text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, + priority: 'immediate', + }) + } + } + + if (goalCompleted) { + yield createCommandInputMessage( + 'Goal marked complete.', + ) + } + + if (goalContinuationReason) { + yield createCommandInputMessage( + `${formatGoalContinuationStatusOutput(goalContinuationReason)}`, + ) + } + + if (preventedContinuation) { + return { blockingErrors: [], preventContinuation: true } + } + + // Collect blocking errors from stop hooks + if (blockingErrors.length > 0) { + return { blockingErrors, preventContinuation: false } + } + + // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate + if (isTeammate()) { + const teammateName = getAgentName() ?? '' + const teamName = getTeamName() ?? '' + const teammateBlockingErrors: Message[] = [] + let teammatePreventedContinuation = false + let teammateStopReason: string | undefined + // Each hook executor generates its own toolUseID 鈥?capture from progress + // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. + let teammateHookToolUseID = '' + + // Run TaskCompleted hooks for any in-progress tasks owned by this teammate + const taskListId = getTaskListId() + const tasks = await listTasks(taskListId) + const inProgressTasks = tasks.filter( + t => t.status === 'in_progress' && t.owner === teammateName, + ) + + for (const task of inProgressTasks) { + const taskCompletedGenerator = executeTaskCompletedHooks( + task.id, + task.subject, + task.description, + teammateName, + teamName, + permissionMode, + toolUseContext.abortController.signal, + undefined, + toolUseContext, + ) + + for await (const result of taskCompletedGenerator) { + if (result.message) { + if ( + result.message.type === 'progress' && + result.message.toolUseID + ) { + teammateHookToolUseID = result.message.toolUseID + } + yield result.message + } + if (result.blockingError) { + const userMessage = createUserMessage({ + content: getTaskCompletedHookMessage(result.blockingError), + isMeta: true, + }) + teammateBlockingErrors.push(userMessage) + yield userMessage + } + // Match Stop hook behavior: allow preventContinuation/stopReason + if (result.preventContinuation) { + teammatePreventedContinuation = true + teammateStopReason = + result.stopReason || 'TaskCompleted hook prevented continuation' + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: teammateStopReason, + hookName: 'TaskCompleted', + toolUseID: teammateHookToolUseID, + hookEvent: 'TaskCompleted', + }) + } + if (toolUseContext.abortController.signal.aborted) { + return { blockingErrors: [], preventContinuation: true } + } + } + } + + // Run TeammateIdle hooks + const teammateIdleGenerator = executeTeammateIdleHooks( + teammateName, + teamName, + permissionMode, + toolUseContext.abortController.signal, + ) + + for await (const result of teammateIdleGenerator) { + if (result.message) { + if (result.message.type === 'progress' && result.message.toolUseID) { + teammateHookToolUseID = result.message.toolUseID + } + yield result.message + } + if (result.blockingError) { + const userMessage = createUserMessage({ + content: getTeammateIdleHookMessage(result.blockingError), + isMeta: true, + }) + teammateBlockingErrors.push(userMessage) + yield userMessage + } + // Match Stop hook behavior: allow preventContinuation/stopReason + if (result.preventContinuation) { + teammatePreventedContinuation = true + teammateStopReason = + result.stopReason || 'TeammateIdle hook prevented continuation' + yield createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: teammateStopReason, + hookName: 'TeammateIdle', + toolUseID: teammateHookToolUseID, + hookEvent: 'TeammateIdle', + }) + } + if (toolUseContext.abortController.signal.aborted) { + return { blockingErrors: [], preventContinuation: true } + } + } + + if (teammatePreventedContinuation) { + return { blockingErrors: [], preventContinuation: true } + } + + if (teammateBlockingErrors.length > 0) { + return { + blockingErrors: teammateBlockingErrors, + preventContinuation: false, + } + } + } + + return { blockingErrors: [], preventContinuation: false } + } catch (error) { + const durationMs = Date.now() - hookStartTime + logEvent('tengu_stop_hook_error', { + duration: durationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + // Yield a system message that is not visible to the model for the user + // to debug their hook. + yield createSystemMessage( + `Stop hook failed: ${errorMessage(error)}`, + 'warning', + ) + return { blockingErrors: [], preventContinuation: false } + } +} diff --git a/hook-dump/src_schemas_hooks.ts b/hook-dump/src_schemas_hooks.ts new file mode 100644 index 0000000000..743af5eb13 --- /dev/null +++ b/hook-dump/src_schemas_hooks.ts @@ -0,0 +1,225 @@ +FILE: E:\Yuanban\cc-haha-src\src\schemas\hooks.ts +LINES: 222 +========== +/** + * Hook Zod schemas extracted to break import cycles. + * + * This file contains hook-related schema definitions that were originally + * in src/utils/settings/types.ts. By extracting them here, we break the + * circular dependency between settings/types.ts and plugins/schemas.ts. + * + * Both files now import from this shared location instead of each other. + */ + +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { z } from 'zod/v4' +import { lazySchema } from '../utils/lazySchema.js' +import { SHELL_TYPES } from '../utils/shell/shellProvider.js' + +// Shared schema for the `if` condition field. +// Uses permission rule syntax (e.g., "Bash(git *)", "Read(*.ts)") to filter hooks +// before spawning. Evaluated against the hook input's tool_name and tool_input. +const IfConditionSchema = lazySchema(() => + z + .string() + .optional() + .describe( + 'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' + + 'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.', + ), +) + +// Internal factory for individual hook schemas (shared between exported +// discriminated union members and the HookCommandSchema factory) +function buildHookSchemas() { + const BashCommandHookSchema = z.object({ + type: z.literal('command').describe('Shell command hook type'), + command: z.string().describe('Shell command to execute'), + if: IfConditionSchema(), + shell: z + .enum(SHELL_TYPES) + .optional() + .describe( + "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.", + ), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific command'), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + async: z + .boolean() + .optional() + .describe('If true, hook runs in background without blocking'), + asyncRewake: z + .boolean() + .optional() + .describe( + 'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.', + ), + }) + + const PromptHookSchema = z.object({ + type: z.literal('prompt').describe('LLM prompt hook type'), + prompt: z + .string() + .describe( + 'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.', + ), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific prompt evaluation'), + // @[MODEL LAUNCH]: Update the example model ID in the .describe() strings below (prompt + agent hooks). + model: z + .string() + .optional() + .describe( + 'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + const HttpHookSchema = z.object({ + type: z.literal('http').describe('HTTP hook type'), + url: z.string().url().describe('URL to POST the hook input JSON to'), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for this specific request'), + headers: z + .record(z.string(), z.string()) + .optional() + .describe( + 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', + ), + allowedEnvVars: z + .array(z.string()) + .optional() + .describe( + 'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + const AgentHookSchema = z.object({ + type: z.literal('agent').describe('Agentic verifier hook type'), + // DO NOT add .transform() here. This schema is used by parseSettingsFile, + // and updateSettingsForSource round-trips the parsed result through + // JSON.stringify 鈥?a transformed function value is silently dropped, + // deleting the user's prompt from settings.json (gh-24920, CC-79). The + // transform (from #10594) wrapped the string in `(_msgs) => prompt` + // for a programmatic-construction use case in ExitPlanModeV2Tool that + // has since been refactored into VerifyPlanExecutionTool, which no + // longer constructs AgentHook objects at all. + prompt: z + .string() + .describe( + 'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.', + ), + if: IfConditionSchema(), + timeout: z + .number() + .positive() + .optional() + .describe('Timeout in seconds for agent execution (default 60)'), + model: z + .string() + .optional() + .describe( + 'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.', + ), + statusMessage: z + .string() + .optional() + .describe('Custom status message to display in spinner while hook runs'), + once: z + .boolean() + .optional() + .describe('If true, hook runs once and is removed after execution'), + }) + + return { + BashCommandHookSchema, + PromptHookSchema, + HttpHookSchema, + AgentHookSchema, + } +} + +/** + * Schema for hook command (excludes function hooks - they can't be persisted) + */ +export const HookCommandSchema = lazySchema(() => { + const { + BashCommandHookSchema, + PromptHookSchema, + AgentHookSchema, + HttpHookSchema, + } = buildHookSchemas() + return z.discriminatedUnion('type', [ + BashCommandHookSchema, + PromptHookSchema, + AgentHookSchema, + HttpHookSchema, + ]) +}) + +/** + * Schema for matcher configuration with multiple hooks + */ +export const HookMatcherSchema = lazySchema(() => + z.object({ + matcher: z + .string() + .optional() + .describe('String pattern to match (e.g. tool names like "Write")'), // String (e.g. Write) to match values related to the hook event, e.g. tool names + hooks: z + .array(HookCommandSchema()) + .describe('List of hooks to execute when the matcher matches'), + }), +) + +/** + * Schema for hooks configuration + * The key is the hook event. The value is an array of matcher configurations. + * Uses partialRecord since not all hook events need to be defined. + */ +export const HooksSchema = lazySchema(() => + z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())), +) + +// Inferred types from schemas +export type HookCommand = z.infer> +export type BashCommandHook = Extract +export type PromptHook = Extract +export type AgentHook = Extract +export type HttpHook = Extract +export type HookMatcher = z.infer> +export type HooksSettings = Partial> diff --git a/hook-dump/src_services_tools_toolHooks.ts b/hook-dump/src_services_tools_toolHooks.ts new file mode 100644 index 0000000000..8af55f2be2 --- /dev/null +++ b/hook-dump/src_services_tools_toolHooks.ts @@ -0,0 +1,655 @@ +FILE: E:\Yuanban\cc-haha-src\src\services\tools\toolHooks.ts +LINES: 652 +========== +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' +import type z from 'zod/v4' +import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' +import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' +import type { HookProgress } from '../../types/hooks.js' +import type { + AssistantMessage, + AttachmentMessage, + ProgressMessage, +} from '../../types/message.js' +import type { PermissionDecision } from '../../types/permissions.js' +import { createAttachmentMessage } from '../../utils/attachments.js' +import { logForDebugging } from '../../utils/debug.js' +import { + executePostToolHooks, + executePostToolUseFailureHooks, + executePreToolHooks, + getPreToolHookBlockingMessage, +} from '../../utils/hooks.js' +import { logError } from '../../utils/log.js' +import { + getRuleBehaviorDescription, + type PermissionDecisionReason, + type PermissionResult, +} from '../../utils/permissions/PermissionResult.js' +import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' +import { formatError } from '../../utils/toolErrors.js' +import { isMcpTool } from '../mcp/utils.js' +import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' + +export type PostToolUseHooksResult = + | MessageUpdateLazy> + | { updatedMCPToolOutput: Output } + +export async function* runPostToolUseHooks( + toolUseContext: ToolUseContext, + tool: Tool, + toolUseID: string, + messageId: string, + toolInput: Record, + toolResponse: Output, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator> { + const postToolStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + let toolOutput = toolResponse + for await (const result of executePostToolHooks( + tool.name, + toolUseID, + toolInput, + toolOutput, + toolUseContext, + permissionMode, + toolUseContext.abortController.signal, + )) { + try { + // Check if we were aborted during hook execution + // IMPORTANT: We emit a cancelled event per hook + if ( + result.message?.type === 'attachment' && + result.message.attachment.type === 'hook_cancelled' + ) { + logEvent('tengu_post_tool_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PostToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUse', + }), + } + continue + } + + // For JSON {decision:"block"} hooks, executeHooks yields two results: + // {blockingError} and {message: hook_blocking_error attachment}. The + // blockingError path below creates that same attachment, so skip it + // here to avoid displaying the block reason twice (#31301). The + // exit-code-2 path only yields {blockingError}, so it's unaffected. + if ( + result.message && + !( + result.message.type === 'attachment' && + result.message.attachment.type === 'hook_blocking_error' + ) + ) { + yield { message: result.message } + } + + if (result.blockingError) { + yield { + message: createAttachmentMessage({ + type: 'hook_blocking_error', + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + blockingError: result.blockingError, + }), + } + } + + // If hook indicated to prevent continuation, yield a stop reason message + if (result.preventContinuation) { + yield { + message: createAttachmentMessage({ + type: 'hook_stopped_continuation', + message: + result.stopReason || 'Execution stopped by PostToolUse hook', + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + return + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + } + + // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool + if (result.updatedMCPToolOutput && isMcpTool(tool)) { + toolOutput = result.updatedMCPToolOutput as Output + yield { + updatedMCPToolOutput: toolOutput, + } + } + } catch (error) { + const postToolDurationMs = Date.now() - postToolStartTime + logEvent('tengu_post_tool_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: postToolDurationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(error), + hookName: `PostToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUse', + }), + } + } + } + } catch (error) { + logError(error) + } +} + +export async function* runPostToolUseFailureHooks( + toolUseContext: ToolUseContext, + tool: Tool, + toolUseID: string, + messageId: string, + processedInput: z.infer, + error: string, + isInterrupt: boolean | undefined, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator< + MessageUpdateLazy> +> { + const postToolStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + const permissionMode = appState.toolPermissionContext.mode + + for await (const result of executePostToolUseFailureHooks( + tool.name, + toolUseID, + processedInput, + error, + toolUseContext, + isInterrupt, + permissionMode, + toolUseContext.abortController.signal, + )) { + try { + // Check if we were aborted during hook execution + if ( + result.message?.type === 'attachment' && + result.message.attachment.type === 'hook_cancelled' + ) { + logEvent('tengu_post_tool_failure_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + continue + } + + // Skip hook_blocking_error in result.message 鈥?blockingError path + // below creates the same attachment (see #31301 / PostToolUse above). + if ( + result.message && + !( + result.message.type === 'attachment' && + result.message.attachment.type === 'hook_blocking_error' + ) + ) { + yield { message: result.message } + } + + if (result.blockingError) { + yield { + message: createAttachmentMessage({ + type: 'hook_blocking_error', + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + blockingError: result.blockingError, + }), + } + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + } + } catch (hookError) { + const postToolDurationMs = Date.now() - postToolStartTime + logEvent('tengu_post_tool_failure_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: postToolDurationMs, + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(hookError), + hookName: `PostToolUseFailure:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PostToolUseFailure', + }), + } + } + } + } catch (outerError) { + logError(outerError) + } +} + +/** + * Resolve a PreToolUse hook's permission result into a final PermissionDecision. + * + * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json + * deny rules or non-bypass ask rules 鈥?checkRuleBasedPermissions still applies + * (inc-4788 analog). + * Also handles the requiresUserInteraction/requireCanUseTool guards and the + * 'ask' forceDecision passthrough. + * + * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts + * (REPL inner calls) so the permission semantics stay in lockstep. + */ +export async function resolveHookPermissionDecision( + hookPermissionResult: PermissionResult | undefined, + tool: Tool, + input: Record, + toolUseContext: ToolUseContext, + canUseTool: CanUseToolFn, + assistantMessage: AssistantMessage, + toolUseID: string, +): Promise<{ + decision: PermissionDecision + input: Record +}> { + const requiresInteraction = tool.requiresUserInteraction?.() + const requireCanUseTool = toolUseContext.requireCanUseTool + + if (hookPermissionResult?.behavior === 'allow') { + const hookInput = hookPermissionResult.updatedInput ?? input + + // Hook provided updatedInput for an interactive tool 鈥?the hook IS the + // user interaction (e.g. headless wrapper that collected AskUserQuestion + // answers). Treat as non-interactive for the rule-check path. + const interactionSatisfied = + requiresInteraction && hookPermissionResult.updatedInput !== undefined + + if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { + logForDebugging( + `Hook approved tool use for ${tool.name}, but canUseTool is required`, + ) + return { + decision: await canUseTool( + tool, + hookInput, + toolUseContext, + assistantMessage, + toolUseID, + ), + input: hookInput, + } + } + + // Hook allow skips the interactive prompt, but deny rules and non-bypass + // ask rules still apply. + const ruleCheck = await checkRuleBasedPermissions( + tool, + hookInput, + toolUseContext, + ) + if (ruleCheck === null) { + logForDebugging( + interactionSatisfied + ? `Hook satisfied user interaction for ${tool.name} via updatedInput` + : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, + ) + return { decision: hookPermissionResult, input: hookInput } + } + if (ruleCheck.behavior === 'deny') { + logForDebugging( + `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, + ) + return { decision: ruleCheck, input: hookInput } + } + // ask rule 鈥?dialog required despite hook approval + logForDebugging( + `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, + ) + return { + decision: await canUseTool( + tool, + hookInput, + toolUseContext, + assistantMessage, + toolUseID, + ), + input: hookInput, + } + } + + if (hookPermissionResult?.behavior === 'deny') { + logForDebugging(`Hook denied tool use for ${tool.name}`) + return { decision: hookPermissionResult, input } + } + + // No hook decision or 'ask' 鈥?normal permission flow, possibly with + // forceDecision so the dialog shows the hook's ask message. + const forceDecision = + hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined + const askInput = + hookPermissionResult?.behavior === 'ask' && + hookPermissionResult.updatedInput + ? hookPermissionResult.updatedInput + : input + return { + decision: await canUseTool( + tool, + askInput, + toolUseContext, + assistantMessage, + toolUseID, + forceDecision, + ), + input: askInput, + } +} + +export async function* runPreToolUseHooks( + toolUseContext: ToolUseContext, + tool: Tool, + processedInput: Record, + toolUseID: string, + messageId: string, + requestId: string | undefined, + mcpServerType: McpServerType, + mcpServerBaseUrl: string | undefined, +): AsyncGenerator< + | { + type: 'message' + message: MessageUpdateLazy< + AttachmentMessage | ProgressMessage + > + } + | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } + | { type: 'hookUpdatedInput'; updatedInput: Record } + | { type: 'preventContinuation'; shouldPreventContinuation: boolean } + | { type: 'stopReason'; stopReason: string } + | { + type: 'additionalContext' + message: MessageUpdateLazy + } + // stop execution + | { type: 'stop' } +> { + const hookStartTime = Date.now() + try { + const appState = toolUseContext.getAppState() + + for await (const result of executePreToolHooks( + tool.name, + toolUseID, + processedInput, + toolUseContext, + appState.toolPermissionContext.mode, + toolUseContext.abortController.signal, + undefined, // timeoutMs - use default + toolUseContext.requestPrompt, + tool.getToolUseSummary?.(processedInput), + )) { + try { + if (result.message) { + yield { type: 'message', message: { message: result.message } } + } + if (result.blockingError) { + const denialMessage = getPreToolHookBlockingMessage( + `PreToolUse:${tool.name}`, + result.blockingError, + ) + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'deny', + message: denialMessage, + decisionReason: { + type: 'hook', + hookName: `PreToolUse:${tool.name}`, + reason: denialMessage, + }, + }, + } + } + // Check if hook wants to prevent continuation + if (result.preventContinuation) { + yield { + type: 'preventContinuation', + shouldPreventContinuation: true, + } + if (result.stopReason) { + yield { type: 'stopReason', stopReason: result.stopReason } + } + } + // Check for hook-defined permission behavior + if (result.permissionBehavior !== undefined) { + logForDebugging( + `Hook result has permissionBehavior=${result.permissionBehavior}`, + ) + const decisionReason: PermissionDecisionReason = { + type: 'hook', + hookName: `PreToolUse:${tool.name}`, + hookSource: result.hookSource, + reason: result.hookPermissionDecisionReason, + } + if (result.permissionBehavior === 'allow') { + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'allow', + updatedInput: result.updatedInput, + decisionReason, + }, + } + } else if (result.permissionBehavior === 'ask') { + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: 'ask', + updatedInput: result.updatedInput, + message: + result.hookPermissionDecisionReason || + `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, + decisionReason, + }, + } + } else { + // deny - updatedInput is irrelevant since tool won't run + yield { + type: 'hookPermissionResult', + hookPermissionResult: { + behavior: result.permissionBehavior, + message: + result.hookPermissionDecisionReason || + `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, + decisionReason, + }, + } + } + } + + // Yield updatedInput for passthrough case (no permission decision) + // This allows hooks to modify input while letting normal permission flow continue + if (result.updatedInput && result.permissionBehavior === undefined) { + yield { + type: 'hookUpdatedInput', + updatedInput: result.updatedInput, + } + } + + // If hooks provided additional context, add it as a message + if (result.additionalContexts && result.additionalContexts.length > 0) { + yield { + type: 'additionalContext', + message: { + message: createAttachmentMessage({ + type: 'hook_additional_context', + content: result.additionalContexts, + hookName: `PreToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + } + + // Check if we were aborted during hook execution + if (toolUseContext.abortController.signal.aborted) { + logEvent('tengu_pre_tool_hooks_cancelled', { + toolName: sanitizeToolNameForAnalytics(tool.name), + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + }) + yield { + type: 'message', + message: { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName: `PreToolUse:${tool.name}`, + toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + yield { type: 'stop' } + return + } + } catch (error) { + logError(error) + const durationMs = Date.now() - hookStartTime + logEvent('tengu_pre_tool_hook_error', { + messageID: + messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: sanitizeToolNameForAnalytics(tool.name), + isMcp: tool.isMcp ?? false, + duration: durationMs, + + queryChainId: toolUseContext.queryTracking + ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + queryDepth: toolUseContext.queryTracking?.depth, + ...(mcpServerType + ? { + mcpServerType: + mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + ...(requestId + ? { + requestId: + requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}), + }) + yield { + type: 'message', + message: { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + content: formatError(error), + hookName: `PreToolUse:${tool.name}`, + toolUseID: toolUseID, + hookEvent: 'PreToolUse', + }), + }, + } + yield { type: 'stop' } + } + } + } catch (error) { + logError(error) + yield { type: 'stop' } + return + } +} diff --git a/hook-dump/src_types_hooks.ts b/hook-dump/src_types_hooks.ts new file mode 100644 index 0000000000..981f34b0af --- /dev/null +++ b/hook-dump/src_types_hooks.ts @@ -0,0 +1,293 @@ +FILE: E:\Yuanban\cc-haha-src\src\types\hooks.ts +LINES: 290 +========== +// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered +import { z } from 'zod/v4' +import { lazySchema } from '../utils/lazySchema.js' +import { + type HookEvent, + HOOK_EVENTS, + type HookInput, + type PermissionUpdate, +} from 'src/entrypoints/agentSdkTypes.js' +import type { + HookJSONOutput, + AsyncHookJSONOutput, + SyncHookJSONOutput, +} from 'src/entrypoints/agentSdkTypes.js' +import type { Message } from 'src/types/message.js' +import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js' +import { permissionBehaviorSchema } from 'src/utils/permissions/PermissionRule.js' +import { permissionUpdateSchema } from 'src/utils/permissions/PermissionUpdateSchema.js' +import type { AppState } from '../state/AppState.js' +import type { AttributionState } from '../utils/commitAttribution.js' + +export function isHookEvent(value: string): value is HookEvent { + return HOOK_EVENTS.includes(value as HookEvent) +} + +// Prompt elicitation protocol types. The `prompt` key acts as discriminator +// (mirroring the {async:true} pattern), with the id as its value. +export const promptRequestSchema = lazySchema(() => + z.object({ + prompt: z.string(), // request id + message: z.string(), + options: z.array( + z.object({ + key: z.string(), + label: z.string(), + description: z.string().optional(), + }), + ), + }), +) + +export type PromptRequest = z.infer> + +export type PromptResponse = { + prompt_response: string // request id + selected: string +} + +// Sync hook response schema +export const syncHookResponseSchema = lazySchema(() => + z.object({ + continue: z + .boolean() + .describe('Whether Claude should continue after hook (default: true)') + .optional(), + suppressOutput: z + .boolean() + .describe('Hide stdout from transcript (default: false)') + .optional(), + stopReason: z + .string() + .describe('Message shown when continue is false') + .optional(), + decision: z.enum(['approve', 'block']).optional(), + reason: z.string().describe('Explanation for the decision').optional(), + systemMessage: z + .string() + .describe('Warning message shown to the user') + .optional(), + hookSpecificOutput: z + .union([ + z.object({ + hookEventName: z.literal('PreToolUse'), + permissionDecision: permissionBehaviorSchema().optional(), + permissionDecisionReason: z.string().optional(), + updatedInput: z.record(z.string(), z.unknown()).optional(), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('UserPromptSubmit'), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('SessionStart'), + additionalContext: z.string().optional(), + initialUserMessage: z.string().optional(), + watchPaths: z + .array(z.string()) + .describe('Absolute paths to watch for FileChanged hooks') + .optional(), + }), + z.object({ + hookEventName: z.literal('Setup'), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('SubagentStart'), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('PostToolUse'), + additionalContext: z.string().optional(), + updatedMCPToolOutput: z + .unknown() + .describe('Updates the output for MCP tools') + .optional(), + }), + z.object({ + hookEventName: z.literal('PostToolUseFailure'), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('PermissionDenied'), + retry: z.boolean().optional(), + }), + z.object({ + hookEventName: z.literal('Notification'), + additionalContext: z.string().optional(), + }), + z.object({ + hookEventName: z.literal('PermissionRequest'), + decision: z.union([ + z.object({ + behavior: z.literal('allow'), + updatedInput: z.record(z.string(), z.unknown()).optional(), + updatedPermissions: z.array(permissionUpdateSchema()).optional(), + }), + z.object({ + behavior: z.literal('deny'), + message: z.string().optional(), + interrupt: z.boolean().optional(), + }), + ]), + }), + z.object({ + hookEventName: z.literal('Elicitation'), + action: z.enum(['accept', 'decline', 'cancel']).optional(), + content: z.record(z.string(), z.unknown()).optional(), + }), + z.object({ + hookEventName: z.literal('ElicitationResult'), + action: z.enum(['accept', 'decline', 'cancel']).optional(), + content: z.record(z.string(), z.unknown()).optional(), + }), + z.object({ + hookEventName: z.literal('CwdChanged'), + watchPaths: z + .array(z.string()) + .describe('Absolute paths to watch for FileChanged hooks') + .optional(), + }), + z.object({ + hookEventName: z.literal('FileChanged'), + watchPaths: z + .array(z.string()) + .describe('Absolute paths to watch for FileChanged hooks') + .optional(), + }), + z.object({ + hookEventName: z.literal('WorktreeCreate'), + worktreePath: z.string(), + }), + ]) + .optional(), + }), +) + +// Zod schema for hook JSON output validation +export const hookJSONOutputSchema = lazySchema(() => { + // Async hook response schema + const asyncHookResponseSchema = z.object({ + async: z.literal(true), + asyncTimeout: z.number().optional(), + }) + return z.union([asyncHookResponseSchema, syncHookResponseSchema()]) +}) + +// Infer the TypeScript type from the schema +type SchemaHookJSONOutput = z.infer> + +// Type guard function to check if response is sync +export function isSyncHookJSONOutput( + json: HookJSONOutput, +): json is SyncHookJSONOutput { + return !('async' in json && json.async === true) +} + +// Type guard function to check if response is async +export function isAsyncHookJSONOutput( + json: HookJSONOutput, +): json is AsyncHookJSONOutput { + return 'async' in json && json.async === true +} + +// Compile-time assertion that SDK and Zod types match +import type { IsEqual } from 'type-fest' +type Assert = T +type _assertSDKTypesMatch = Assert< + IsEqual +> + +/** Context passed to callback hooks for state access */ +export type HookCallbackContext = { + getAppState: () => AppState + updateAttributionState: ( + updater: (prev: AttributionState) => AttributionState, + ) => void +} + +/** Hook that is a callback. */ +export type HookCallback = { + type: 'callback' + callback: ( + input: HookInput, + toolUseID: string | null, + abort: AbortSignal | undefined, + /** Hook index for SessionStart hooks to compute CLAUDE_ENV_FILE path */ + hookIndex?: number, + /** Optional context for accessing app state */ + context?: HookCallbackContext, + ) => Promise + /** Timeout in seconds for this hook */ + timeout?: number + /** Internal hooks (e.g. session file access analytics) are excluded from tengu_run_hook metrics */ + internal?: boolean +} + +export type HookCallbackMatcher = { + matcher?: string + hooks: HookCallback[] + pluginName?: string +} + +export type HookProgress = { + type: 'hook_progress' + hookEvent: HookEvent + hookName: string + command: string + promptText?: string + statusMessage?: string +} + +export type HookBlockingError = { + blockingError: string + command: string +} + +export type PermissionRequestResult = + | { + behavior: 'allow' + updatedInput?: Record + updatedPermissions?: PermissionUpdate[] + } + | { + behavior: 'deny' + message?: string + interrupt?: boolean + } + +export type HookResult = { + message?: Message + systemMessage?: Message + blockingError?: HookBlockingError + outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' + preventContinuation?: boolean + stopReason?: string + permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' + hookPermissionDecisionReason?: string + additionalContext?: string + initialUserMessage?: string + updatedInput?: Record + updatedMCPToolOutput?: unknown + permissionRequestResult?: PermissionRequestResult + retry?: boolean +} + +export type AggregatedHookResult = { + message?: Message + blockingErrors?: HookBlockingError[] + preventContinuation?: boolean + stopReason?: string + hookPermissionDecisionReason?: string + permissionBehavior?: PermissionResult['behavior'] + additionalContexts?: string[] + initialUserMessage?: string + updatedInput?: Record + updatedMCPToolOutput?: unknown + permissionRequestResult?: PermissionRequestResult + retry?: boolean +} diff --git a/hook-dump/src_utils_hooks.ts b/hook-dump/src_utils_hooks.ts new file mode 100644 index 0000000000..1cdd58b7bc --- /dev/null +++ b/hook-dump/src_utils_hooks.ts @@ -0,0 +1,5044 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks.ts +LINES: 5041 +========== +// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered +/** + * Hooks are user-defined shell commands that can be executed at various points + * in Claude Code's lifecycle. + */ +import { basename } from 'path' +import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' +import { pathExists } from './file.js' +import { wrapSpawn } from './ShellCommand.js' +import { TaskOutput } from './task/TaskOutput.js' +import { getCwd } from './cwd.js' +import { randomUUID } from 'crypto' +import { formatShellPrefixCommand } from './bash/shellPrefix.js' +import { + getHookEnvFilePath, + invalidateSessionEnvCache, +} from './sessionEnvironment.js' +import { subprocessEnv } from './subprocessEnv.js' +import { getPlatform } from './platform.js' +import { + tryFindGitBashPath, + windowsPathToPosixPath, +} from './windowsPaths.js' +import { getCachedPowerShellPath } from './shell/powershellDetection.js' +import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js' +import { buildPowerShellArgs } from './shell/powershellProvider.js' +import { + loadPluginOptions, + substituteUserConfigVariables, +} from './plugins/pluginOptionsStorage.js' +import { getPluginDataDir } from './plugins/pluginDirectories.js' +import { + getSessionId, + getProjectRoot, + getIsNonInteractiveSession, + getRegisteredHooks, + getStatsStore, + addToTurnHookDuration, + getOriginalCwd, + getMainThreadAgentType, +} from '../bootstrap/state.js' +import { checkHasTrustDialogAccepted } from './config.js' +import { + getHooksConfigFromSnapshot, + shouldAllowManagedHooksOnly, + shouldDisableAllHooksIncludingManaged, +} from './hooks/hooksConfigSnapshot.js' +import { + getTranscriptPathForSession, + getAgentTranscriptPath, +} from './sessionStorage.js' +import type { AgentId } from '../types/ids.js' +import { + getSettings_DEPRECATED, + getSettingsForSource, +} from './settings/settings.js' +import { + logEvent, + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, +} from 'src/services/analytics/index.js' +import { logOTelEvent } from './telemetry/events.js' +import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js' +import { + startHookSpan, + endHookSpan, + isBetaTracingEnabled, +} from './telemetry/sessionTracing.js' +import { + hookJSONOutputSchema, + promptRequestSchema, + type HookCallback, + type HookCallbackMatcher, + type PromptRequest, + type PromptResponse, + isAsyncHookJSONOutput, + isSyncHookJSONOutput, + type PermissionRequestResult, +} from '../types/hooks.js' +import type { + HookEvent, + HookInput, + HookJSONOutput, + NotificationHookInput, + PostToolUseHookInput, + PostToolUseFailureHookInput, + PermissionDeniedHookInput, + PreCompactHookInput, + PostCompactHookInput, + PreToolUseHookInput, + SessionStartHookInput, + SessionEndHookInput, + SetupHookInput, + StopHookInput, + StopFailureHookInput, + SubagentStartHookInput, + SubagentStopHookInput, + TeammateIdleHookInput, + TaskCreatedHookInput, + TaskCompletedHookInput, + ConfigChangeHookInput, + CwdChangedHookInput, + FileChangedHookInput, + InstructionsLoadedHookInput, + UserPromptSubmitHookInput, + PermissionRequestHookInput, + ElicitationHookInput, + ElicitationResultHookInput, + PermissionUpdate, + ExitReason, + SyncHookJSONOutput, + AsyncHookJSONOutput, +} from 'src/entrypoints/agentSdkTypes.js' +import type { StatusLineCommandInput } from '../types/statusLine.js' +import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js' +import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js' +import type { HookResultMessage } from 'src/types/message.js' +import chalk from 'chalk' +import type { + HookMatcher, + HookCommand, + PluginHookMatcher, + SkillHookMatcher, +} from './settings/types.js' +import { getHookDisplayText } from './hooks/hooksSettings.js' +import { logForDebugging } from './debug.js' +import { logForDiagnosticsNoPII } from './diagLogs.js' +import { firstLineOf } from './stringUtils.js' +import { + normalizeLegacyToolName, + getLegacyToolNames, + permissionRuleValueFromString, +} from './permissions/permissionRuleParser.js' +import { logError } from './log.js' +import { createCombinedAbortSignal } from './combinedAbortSignal.js' +import type { PermissionResult } from './permissions/PermissionResult.js' +import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' +import { enqueuePendingNotification } from './messageQueueManager.js' +import { + extractTextContent, + getLastAssistantMessage, + wrapInSystemReminder, +} from './messages.js' +import { + emitHookStarted, + emitHookResponse, + startHookProgressInterval, +} from './hooks/hookEvents.js' +import { createAttachmentMessage } from './attachments.js' +import { all } from './generators.js' +import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' +import { execPromptHook } from './hooks/execPromptHook.js' +import type { Message, AssistantMessage } from '../types/message.js' +import { execAgentHook } from './hooks/execAgentHook.js' +import { execHttpHook } from './hooks/execHttpHook.js' +import type { ShellCommand } from './ShellCommand.js' +import { + getSessionHooks, + getSessionFunctionHooks, + getSessionHookCallback, + clearSessionHooks, + type SessionDerivedHookMatcher, + type FunctionHook, +} from './hooks/sessionHooks.js' +import type { AppState } from '../state/AppState.js' +import { jsonStringify, jsonParse } from './slowOperations.js' +import { isEnvTruthy } from './envUtils.js' +import { errorMessage, getErrnoCode } from './errors.js' + +const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 + +/** + * SessionEnd hooks run during shutdown/clear and need a much tighter bound + * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both + * the per-hook default timeout AND the overall AbortSignal cap (hooks run in + * parallel, so one value suffices). Overridable via env var for users whose + * teardown scripts need more time. + */ +const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 +export function getSessionEndHookTimeoutMs(): number { + const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS + const parsed = raw ? parseInt(raw, 10) : NaN + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT +} + +function executeInBackground({ + processId, + hookId, + shellCommand, + asyncResponse, + hookEvent, + hookName, + command, + asyncRewake, + pluginId, +}: { + processId: string + hookId: string + shellCommand: ShellCommand + asyncResponse: AsyncHookJSONOutput + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + hookName: string + command: string + asyncRewake?: boolean + pluginId?: string +}): boolean { + if (asyncRewake) { + // asyncRewake hooks bypass the registry entirely. On completion, if exit + // code 2 (blocking error), enqueue as a task-notification so it wakes the + // model via useQueueProcessor (idle) or gets injected mid-query via + // queued_command attachments (busy). + // + // NOTE: We deliberately do NOT call shellCommand.background() here, because + // it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr + // capture (getStderr() returns '' in disk mode). The StreamWrappers stay + // attached and pipe data into the in-memory TaskOutput buffers. The abort + // handler already no-ops on 'interrupt' reason (user submitted a new + // message), so the hook survives new prompts. A hard cancel (Escape) WILL + // kill the hook via the abort handler, which is the desired behavior. + void shellCommand.result.then(async result => { + // result resolves on 'exit', but stdio 'data' events may still be + // pending. Yield to I/O so the StreamWrapper data handlers drain into + // TaskOutput before we read it. + await new Promise(resolve => setImmediate(resolve)) + const stdout = await shellCommand.taskOutput.getStdout() + const stderr = shellCommand.taskOutput.getStderr() + shellCommand.cleanup() + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: stdout + stderr, + stdout, + stderr, + exitCode: result.code, + outcome: result.code === 0 ? 'success' : 'error', + }) + if (result.code === 2) { + enqueuePendingNotification({ + value: wrapInSystemReminder( + `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`, + ), + mode: 'task-notification', + }) + } + }) + return true + } + + // TaskOutput on the ShellCommand accumulates data 鈥?no stream listeners needed + if (!shellCommand.background(processId)) { + return false + } + + registerPendingAsyncHook({ + processId, + hookId, + asyncResponse, + hookEvent, + hookName, + command, + shellCommand, + pluginId, + }) + + return true +} + +/** + * Checks if a hook should be skipped due to lack of workspace trust. + * + * ALL hooks require workspace trust because they execute arbitrary commands from + * .claude/settings.json. This is a defense-in-depth security measure. + * + * Context: Hooks are captured via captureHooksConfigSnapshot() before the trust + * dialog is shown. While most hooks won't execute until after trust is established + * through normal program flow, enforcing trust for ALL hooks prevents: + * - Future bugs where a hook might accidentally execute before trust + * - Any codepath that might trigger hooks before trust dialog + * - Security issues from hook execution in untrusted workspaces + * + * Historical vulnerabilities that prompted this check: + * - SessionEnd hooks executing when user declines trust dialog + * - SubagentStop hooks executing when subagent completes before trust + * + * @returns true if hook should be skipped, false if it should execute + */ +export function shouldSkipHookDueToTrust(): boolean { + // In non-interactive mode (SDK), trust is implicit - always execute + const isInteractive = !getIsNonInteractiveSession() + if (!isInteractive) { + return false + } + + // In interactive mode, ALL hooks require trust + const hasTrust = checkHasTrustDialogAccepted() + return !hasTrust +} + +/** + * Creates the base hook input that's common to all hook types + */ +export function createBaseHookInput( + permissionMode?: string, + sessionId?: string, + // Typed narrowly (not ToolUseContext) so callers can pass toolUseContext + // directly via structural typing without this function depending on Tool.ts. + agentInfo?: { agentId?: string; agentType?: string }, +): { + session_id: string + transcript_path: string + cwd: string + permission_mode?: string + agent_id?: string + agent_type?: string +} { + const resolvedSessionId = sessionId ?? getSessionId() + // agent_type: subagent's type (from toolUseContext) takes precedence over + // the session's --agent flag. Hooks use agent_id presence to distinguish + // subagent calls from main-thread calls in a --agent session. + const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType() + return { + session_id: resolvedSessionId, + transcript_path: getTranscriptPathForSession(resolvedSessionId), + cwd: getCwd(), + permission_mode: permissionMode, + agent_id: agentInfo?.agentId, + agent_type: resolvedAgentType, + } +} + +export interface HookBlockingError { + blockingError: string + command: string +} + +/** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */ +export type ElicitationResponse = ElicitResult + +export interface HookResult { + message?: HookResultMessage + systemMessage?: string + blockingError?: HookBlockingError + outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' + preventContinuation?: boolean + stopReason?: string + permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' + hookPermissionDecisionReason?: string + additionalContext?: string + initialUserMessage?: string + updatedInput?: Record + updatedMCPToolOutput?: unknown + permissionRequestResult?: PermissionRequestResult + elicitationResponse?: ElicitationResponse + watchPaths?: string[] + elicitationResultResponse?: ElicitationResponse + retry?: boolean + hook: HookCommand | HookCallback | FunctionHook +} + +export type AggregatedHookResult = { + message?: HookResultMessage + blockingError?: HookBlockingError + preventContinuation?: boolean + stopReason?: string + hookPermissionDecisionReason?: string + hookSource?: string + permissionBehavior?: PermissionResult['behavior'] + additionalContexts?: string[] + initialUserMessage?: string + updatedInput?: Record + updatedMCPToolOutput?: unknown + permissionRequestResult?: PermissionRequestResult + watchPaths?: string[] + elicitationResponse?: ElicitationResponse + elicitationResultResponse?: ElicitationResponse + retry?: boolean +} + +/** + * Parse and validate a JSON string against the hook output Zod schema. + * Returns the validated output or formatted validation errors. + */ +function validateHookJson( + jsonString: string, +): { json: HookJSONOutput } | { validationError: string } { + const parsed = jsonParse(jsonString) + const validation = hookJSONOutputSchema().safeParse(parsed) + if (validation.success) { + logForDebugging('Successfully parsed and validated hook JSON output') + return { json: validation.data } + } + const errors = validation.error.issues + .map(err => ` - ${err.path.join('.')}: ${err.message}`) + .join('\n') + return { + validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`, + } +} + +function parseHookOutput(stdout: string): { + json?: HookJSONOutput + plainText?: string + validationError?: string +} { + const trimmed = stdout.trim() + if (!trimmed.startsWith('{')) { + logForDebugging('Hook output does not start with {, treating as plain text') + return { plainText: stdout } + } + + try { + const result = validateHookJson(trimmed) + if ('json' in result) { + return result + } + // For command hooks, include the schema hint in the error message + const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify( + { + continue: 'boolean (optional)', + suppressOutput: 'boolean (optional)', + stopReason: 'string (optional)', + decision: '"approve" | "block" (optional)', + reason: 'string (optional)', + systemMessage: 'string (optional)', + permissionDecision: '"allow" | "deny" | "ask" (optional)', + hookSpecificOutput: { + 'for PreToolUse': { + hookEventName: '"PreToolUse"', + permissionDecision: '"allow" | "deny" | "ask" (optional)', + permissionDecisionReason: 'string (optional)', + updatedInput: 'object (optional) - Modified tool input to use', + }, + 'for UserPromptSubmit': { + hookEventName: '"UserPromptSubmit"', + additionalContext: 'string (required)', + }, + 'for PostToolUse': { + hookEventName: '"PostToolUse"', + additionalContext: 'string (optional)', + }, + }, + }, + null, + 2, + )}` + logForDebugging(errorMessage) + return { plainText: stdout, validationError: errorMessage } + } catch (e) { + logForDebugging(`Failed to parse hook output as JSON: ${e}`) + return { plainText: stdout } + } +} + +function parseHttpHookOutput(body: string): { + json?: HookJSONOutput + validationError?: string +} { + const trimmed = body.trim() + + if (trimmed === '') { + const validation = hookJSONOutputSchema().safeParse({}) + if (validation.success) { + logForDebugging( + 'HTTP hook returned empty body, treating as empty JSON object', + ) + return { json: validation.data } + } + } + + if (!trimmed.startsWith('{')) { + const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}` + logForDebugging(validationError) + return { validationError } + } + + try { + const result = validateHookJson(trimmed) + if ('json' in result) { + return result + } + logForDebugging(result.validationError) + return result + } catch (e) { + const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}` + logForDebugging(validationError) + return { validationError } + } +} + +function processHookJSONOutput({ + json, + command, + hookName, + toolUseID, + hookEvent, + expectedHookEvent, + stdout, + stderr, + exitCode, + durationMs, +}: { + json: SyncHookJSONOutput + command: string + hookName: string + toolUseID: string + hookEvent: HookEvent + expectedHookEvent?: HookEvent + stdout?: string + stderr?: string + exitCode?: number + durationMs?: number +}): Partial { + const result: Partial = {} + + // At this point we know it's a sync response + const syncJson = json + + // Handle common elements + if (syncJson.continue === false) { + result.preventContinuation = true + if (syncJson.stopReason) { + result.stopReason = syncJson.stopReason + } + } + + if (json.decision) { + switch (json.decision) { + case 'approve': + result.permissionBehavior = 'allow' + break + case 'block': + result.permissionBehavior = 'deny' + result.blockingError = { + blockingError: json.reason || 'Blocked by hook', + command, + } + break + default: + // Handle unknown decision types as errors + throw new Error( + `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`, + ) + } + } + + // Handle systemMessage field + if (json.systemMessage) { + result.systemMessage = json.systemMessage + } + + // Handle PreToolUse specific + if ( + json.hookSpecificOutput?.hookEventName === 'PreToolUse' && + json.hookSpecificOutput.permissionDecision + ) { + switch (json.hookSpecificOutput.permissionDecision) { + case 'allow': + result.permissionBehavior = 'allow' + break + case 'deny': + result.permissionBehavior = 'deny' + result.blockingError = { + blockingError: json.reason || 'Blocked by hook', + command, + } + break + case 'ask': + result.permissionBehavior = 'ask' + break + default: + // Handle unknown decision types as errors + throw new Error( + `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`, + ) + } + } + if (result.permissionBehavior !== undefined && json.reason !== undefined) { + result.hookPermissionDecisionReason = json.reason + } + + // Handle hookSpecificOutput + if (json.hookSpecificOutput) { + // Validate hook event name matches expected if provided + if ( + expectedHookEvent && + json.hookSpecificOutput.hookEventName !== expectedHookEvent + ) { + throw new Error( + `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`, + ) + } + + switch (json.hookSpecificOutput.hookEventName) { + case 'PreToolUse': + // Override with more specific permission decision if provided + if (json.hookSpecificOutput.permissionDecision) { + switch (json.hookSpecificOutput.permissionDecision) { + case 'allow': + result.permissionBehavior = 'allow' + break + case 'deny': + result.permissionBehavior = 'deny' + result.blockingError = { + blockingError: + json.hookSpecificOutput.permissionDecisionReason || + json.reason || + 'Blocked by hook', + command, + } + break + case 'ask': + result.permissionBehavior = 'ask' + break + } + } + result.hookPermissionDecisionReason = + json.hookSpecificOutput.permissionDecisionReason + // Extract updatedInput if provided + if (json.hookSpecificOutput.updatedInput) { + result.updatedInput = json.hookSpecificOutput.updatedInput + } + // Extract additionalContext if provided + result.additionalContext = json.hookSpecificOutput.additionalContext + break + case 'UserPromptSubmit': + result.additionalContext = json.hookSpecificOutput.additionalContext + break + case 'SessionStart': + result.additionalContext = json.hookSpecificOutput.additionalContext + result.initialUserMessage = json.hookSpecificOutput.initialUserMessage + if ( + 'watchPaths' in json.hookSpecificOutput && + json.hookSpecificOutput.watchPaths + ) { + result.watchPaths = json.hookSpecificOutput.watchPaths + } + break + case 'Setup': + result.additionalContext = json.hookSpecificOutput.additionalContext + break + case 'SubagentStart': + result.additionalContext = json.hookSpecificOutput.additionalContext + break + case 'PostToolUse': + result.additionalContext = json.hookSpecificOutput.additionalContext + // Extract updatedMCPToolOutput if provided + if (json.hookSpecificOutput.updatedMCPToolOutput) { + result.updatedMCPToolOutput = + json.hookSpecificOutput.updatedMCPToolOutput + } + break + case 'PostToolUseFailure': + result.additionalContext = json.hookSpecificOutput.additionalContext + break + case 'PermissionDenied': + result.retry = json.hookSpecificOutput.retry + break + case 'PermissionRequest': + // Extract the permission request decision + if (json.hookSpecificOutput.decision) { + result.permissionRequestResult = json.hookSpecificOutput.decision + // Also update permissionBehavior for consistency + result.permissionBehavior = + json.hookSpecificOutput.decision.behavior === 'allow' + ? 'allow' + : 'deny' + if ( + json.hookSpecificOutput.decision.behavior === 'allow' && + json.hookSpecificOutput.decision.updatedInput + ) { + result.updatedInput = json.hookSpecificOutput.decision.updatedInput + } + } + break + case 'Elicitation': + if (json.hookSpecificOutput.action) { + result.elicitationResponse = { + action: json.hookSpecificOutput.action, + content: json.hookSpecificOutput.content as + | ElicitationResponse['content'] + | undefined, + } + if (json.hookSpecificOutput.action === 'decline') { + result.blockingError = { + blockingError: json.reason || 'Elicitation denied by hook', + command, + } + } + } + break + case 'ElicitationResult': + if (json.hookSpecificOutput.action) { + result.elicitationResultResponse = { + action: json.hookSpecificOutput.action, + content: json.hookSpecificOutput.content as + | ElicitationResponse['content'] + | undefined, + } + if (json.hookSpecificOutput.action === 'decline') { + result.blockingError = { + blockingError: + json.reason || 'Elicitation result blocked by hook', + command, + } + } + } + break + } + } + + return { + ...result, + message: result.blockingError + ? createAttachmentMessage({ + type: 'hook_blocking_error', + hookName, + toolUseID, + hookEvent, + blockingError: result.blockingError, + }) + : createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID, + hookEvent, + // JSON-output hooks inject context via additionalContext 鈫? + // hook_additional_context, not this field. Empty content suppresses + // the trivial "X hook success: Success" system-reminder that + // otherwise pollutes every turn (messages.ts:3577 skips on ''). + content: '', + stdout, + stderr, + exitCode, + command, + durationMs, + }), + } +} + +/** + * Execute a command-based hook using bash or PowerShell. + * + * Shell resolution: hook.shell 鈫?'bash'. PowerShell hooks spawn pwsh + * with -NoProfile -NonInteractive -Command and skip bash-specific prep + * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX). + * See docs/design/ps-shell-selection.md 搂5.1. + */ +async function execCommandHook( + hook: HookCommand & { type: 'command' }, + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion', + hookName: string, + jsonInput: string, + signal: AbortSignal, + hookId: string, + hookIndex?: number, + pluginRoot?: string, + pluginId?: string, + skillRoot?: string, + forceSyncExecution?: boolean, + requestPrompt?: (request: PromptRequest) => Promise, +): Promise<{ + stdout: string + stderr: string + output: string + status: number + aborted?: boolean + backgrounded?: boolean +}> { + // Gated to once-per-session events to keep diag_log volume bounded. + // started/completed live inside the try/finally so setup-path throws + // don't orphan a started marker 鈥?that'd be indistinguishable from a hang. + const shouldEmitDiag = + hookEvent === 'SessionStart' || + hookEvent === 'Setup' || + hookEvent === 'SessionEnd' + const diagStartMs = Date.now() + let diagExitCode: number | undefined + let diagAborted = false + + const isWindows = getPlatform() === 'windows' + + // -- + // Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md). + // Resolution order: hook.shell 鈫?DEFAULT_HOOK_SHELL. The defaultShell + // fallback (settings.defaultShell) is phase 2 鈥?not wired yet. + // + // The bash path is the historical default and stays unchanged. The + // PowerShell path deliberately skips the Windows-specific bash + // accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted + // SHELL_PREFIX). + let shellType = hook.shell ?? DEFAULT_HOOK_SHELL + + if (isWindows && !hook.shell && shellType === 'bash' && !tryFindGitBashPath()) { + const pwshPath = await getCachedPowerShellPath() + if (pwshPath) { + shellType = 'powershell' + logForDebugging( + `Hooks: Git Bash unavailable on Windows, falling back to PowerShell for default shell on hook "${hook.command}"`, + { level: 'warn' }, + ) + } + } + + const usesPowerShell = shellType === 'powershell' + + // -- + // Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe. + // + // This means every path we put into env vars or substitute into the command + // string MUST be a POSIX path (/c/Users/foo), not a Windows path + // (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths. + // + // windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out): + // C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized + // (LRU-500) so repeated calls are cheap. + // + // PowerShell path: use native paths 鈥?skip the conversion entirely. + // PowerShell expects Windows paths on Windows (and native paths on + // Unix where pwsh is also available). + const toHookPath = + isWindows && !usesPowerShell + ? (p: string) => windowsPathToPosixPath(p) + : (p: string) => p + + // Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path). + // getProjectRoot() is never updated when entering a worktree, so hooks that + // reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root. + const projectDir = getProjectRoot() + + // Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string. + // Order matches MCP/LSP (plugin vars FIRST, then user config) so a user- + // entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated + // as opaque 鈥?not re-interpreted as a template. + let command = hook.command + let pluginOpts: ReturnType | undefined + if (pluginRoot) { + // Plugin directory gone (orphan GC race, concurrent session deleted it): + // throw so callers yield a non-blocking error. Running would fail 鈥?and + // `python3 .py` exits 2, the hook protocol's "block" code, which + // bricks UserPromptSubmit/Stop until restart. The pre-check is necessary + // because exit-2-from-missing-script is indistinguishable from an + // intentional block after spawn. + if (!(await pathExists(pluginRoot))) { + throw new Error( + `Plugin directory does not exist: ${pluginRoot}` + + (pluginId ? ` (${pluginId} 鈥?run /plugin to reinstall)` : ''), + ) + } + // Inline both ROOT and DATA substitution instead of calling + // substitutePluginVariables(). That helper normalizes \ 鈫?/ on Windows + // unconditionally 鈥?correct for bash (toHookPath already produced /c/... + // so it's a no-op) but wrong for PS where toHookPath is identity and we + // want native C:\... backslashes. Inlining also lets us use the function- + // form .replace() so paths containing $ aren't mangled by $-pattern + // interpretation (rare but possible: \\server\c$\plugin). + const rootPath = toHookPath(pluginRoot) + command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath) + if (pluginId) { + const dataPath = toHookPath(getPluginDataDir(pluginId)) + command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath) + } + if (pluginId) { + pluginOpts = loadPluginOptions(pluginId) + // Throws if a referenced key is missing 鈥?that means the hook uses a key + // that's either not declared in manifest.userConfig or not yet configured. + // Caught upstream like any other hook exec failure. + command = substituteUserConfigVariables(command, pluginOpts) + } + } + + // On Windows (bash only), auto-prepend `bash` for .sh scripts so they + // execute instead of opening in the default file handler. PowerShell + // runs .ps1 files natively 鈥?no prepend needed. + if (isWindows && !usesPowerShell && command.trim().match(/\.sh(\s|$|")/)) { + if (!command.trim().startsWith('bash ')) { + command = `bash ${command}` + } + } + + // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting + // (formatShellPrefixCommand uses shell-quote). This makes no sense for + // PowerShell 鈥?see design 搂8.1. For now PS hooks ignore the prefix; + // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up. + const finalCommand = + !usesPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX + ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command) + : command + + const hookTimeoutMs = hook.timeout + ? hook.timeout * 1000 + : TOOL_HOOK_EXECUTION_TIMEOUT_MS + + // Build env vars 鈥?all paths go through toHookPath for Windows POSIX conversion + const envVars: NodeJS.ProcessEnv = { + ...subprocessEnv(), + CLAUDE_PROJECT_DIR: toHookPath(projectDir), + } + + // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same + // name for consistency 鈥?skills can migrate to plugins without code changes) + if (pluginRoot) { + envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot) + if (pluginId) { + envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId)) + } + } + // Expose plugin options as env vars too, so hooks can read them without + // ${user_config.X} in the command string. Sensitive values included 鈥?hooks + // run the user's own code, same trust boundary as reading keychain directly. + if (pluginOpts) { + for (const [key, value] of Object.entries(pluginOpts)) { + // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema + // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is + // belt-and-suspenders, but cheap insurance if someone bypasses the schema. + const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase() + envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value) + } + } + if (skillRoot) { + envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot) + } + + // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var + // definitions into; getSessionEnvironmentScript() concatenates them and + // bashProvider injects the content into bash commands. A PS hook would + // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse. + // Skip for PS 鈥?consistent with how .sh prepend and SHELL_PREFIX are + // already bash-only above. + if ( + !usesPowerShell && + (hookEvent === 'SessionStart' || + hookEvent === 'Setup' || + hookEvent === 'CwdChanged' || + hookEvent === 'FileChanged') && + hookIndex !== undefined + ) { + envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex) + } + + // When agent worktrees are removed, getCwd() may return a deleted path via + // AsyncLocalStorage. Validate before spawning since spawn() emits async + // 'error' events for missing cwd rather than throwing synchronously. + const hookCwd = getCwd() + const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd() + if (safeCwd !== hookCwd) { + logForDebugging( + `Hooks: cwd ${hookCwd} not found, falling back to original cwd`, + { level: 'warn' }, + ) + } + + // -- + // Spawn. Two completely separate paths: + // + // Bash: spawn(cmd, [], { shell: }) 鈥?the shell + // option makes Node pass the whole string to the shell for parsing. + // + // PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive', + // '-Command', cmd]) 鈥?explicit argv, no shell option. -NoProfile + // skips user profile scripts (faster, deterministic). + // -NonInteractive fails fast instead of prompting. + // + // On Windows, default-shell hooks now degrade to PowerShell when Git Bash + // is unavailable. Explicit bash hooks still surface an actionable error, + // but they no longer hard-exit the entire process. + let child: ChildProcessWithoutNullStreams + if (shellType === 'powershell') { + const pwshPath = await getCachedPowerShellPath() + if (!pwshPath) { + throw new Error( + `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` + + `executable (pwsh or powershell) was found on PATH. Install ` + + `PowerShell, or remove "shell": "powershell" to use bash.`, + ) + } + child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { + env: envVars, + cwd: safeCwd, + // Prevent visible console window on Windows (no-op on other platforms) + windowsHide: true, + }) as ChildProcessWithoutNullStreams + } else { + // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax). + // On other platforms, shell: true uses /bin/sh. + const shell = isWindows + ? tryFindGitBashPath() ?? + (() => { + throw new Error( + 'Git Bash is required to run bash hooks on Windows. Install Git for Windows or configure the hook with shell: "powershell".', + ) + })() + : true + child = spawn(finalCommand, [], { + env: envVars, + cwd: safeCwd, + shell, + // Prevent visible console window on Windows (no-op on other platforms) + windowsHide: true, + }) as ChildProcessWithoutNullStreams + } + + // Hooks use pipe mode 鈥?stdout must be streamed into JS so we can parse + // the first response line to detect async hooks ({"async": true}). + const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null) + const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput) + // Track whether shellCommand ownership was transferred (e.g., to async hook registry) + let shellCommandTransferred = false + // Track whether stdin has already been written (to avoid "write after end" errors) + let stdinWritten = false + + if ((hook.async || hook.asyncRewake) && !forceSyncExecution) { + const processId = `async_hook_${child.pid}` + logForDebugging( + `Hooks: Config-based async hook, backgrounding process ${processId}`, + ) + + // Write stdin before backgrounding so the hook receives its input. + // The trailing newline matches the sync path (L1000). Without it, + // bash `read -r line` returns exit 1 (EOF before delimiter) 鈥?the + // variable IS populated but `if read -r line; then ...` skips the + // branch. See gh-30509 / CC-161. + child.stdin.write(jsonInput + '\n', 'utf8') + child.stdin.end() + stdinWritten = true + + const backgrounded = executeInBackground({ + processId, + hookId, + shellCommand, + asyncResponse: { async: true, asyncTimeout: hookTimeoutMs }, + hookEvent, + hookName, + command: hook.command, + asyncRewake: hook.asyncRewake, + pluginId, + }) + if (backgrounded) { + return { + stdout: '', + stderr: '', + output: '', + status: 0, + backgrounded: true, + } + } + } + + let stdout = '' + let stderr = '' + let output = '' + + // Set up output data collection with explicit UTF-8 encoding + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + + let initialResponseChecked = false + + let asyncResolve: + | ((result: { + stdout: string + stderr: string + output: string + status: number + }) => void) + | null = null + const childIsAsyncPromise = new Promise<{ + stdout: string + stderr: string + output: string + status: number + aborted?: boolean + }>(resolve => { + asyncResolve = resolve + }) + + // Track trimmed prompt-request lines we processed so we can strip them + // from final stdout by content match (no index tracking 鈫?no index drift) + const processedPromptLines = new Set() + // Serialize async prompt handling so responses are sent in order + let promptChain = Promise.resolve() + // Line buffer for detecting prompt requests in streaming output + let lineBuffer = '' + + child.stdout.on('data', data => { + stdout += data + output += data + + // When requestPrompt is provided, parse stdout line-by-line for prompt requests + if (requestPrompt) { + lineBuffer += data + const lines = lineBuffer.split('\n') + lineBuffer = lines.pop() ?? '' // last element is an incomplete line + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed) continue + + try { + const parsed = jsonParse(trimmed) + const validation = promptRequestSchema().safeParse(parsed) + if (validation.success) { + processedPromptLines.add(trimmed) + logForDebugging( + `Hooks: Detected prompt request from hook: ${trimmed}`, + ) + // Chain the async handling to serialize prompt responses + const promptReq = validation.data + const reqPrompt = requestPrompt + promptChain = promptChain.then(async () => { + try { + const response = await reqPrompt(promptReq) + child.stdin.write(jsonStringify(response) + '\n', 'utf8') + } catch (err) { + logForDebugging(`Hooks: Prompt request handling failed: ${err}`) + // User cancelled or prompt failed 鈥?close stdin so the hook + // process doesn't hang waiting for input + child.stdin.destroy() + } + }) + continue + } + } catch { + // Not JSON, just a normal line + } + } + } + + // Check for async response on first line of output. The async protocol is: + // hook emits {"async":true,...} as its FIRST line, then its normal output. + // We must parse ONLY the first line 鈥?if the process is fast and writes more + // before this 'data' event fires, parsing the full accumulated stdout fails + // and an async hook blocks for its full duration instead of backgrounding. + if (!initialResponseChecked) { + const firstLine = firstLineOf(stdout).trim() + if (!firstLine.includes('}')) return + initialResponseChecked = true + logForDebugging(`Hooks: Checking first line for async: ${firstLine}`) + try { + const parsed = jsonParse(firstLine) + logForDebugging( + `Hooks: Parsed initial response: ${jsonStringify(parsed)}`, + ) + if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) { + const processId = `async_hook_${child.pid}` + logForDebugging( + `Hooks: Detected async hook, backgrounding process ${processId}`, + ) + + const backgrounded = executeInBackground({ + processId, + hookId, + shellCommand, + asyncResponse: parsed, + hookEvent, + hookName, + command: hook.command, + pluginId, + }) + if (backgrounded) { + shellCommandTransferred = true + asyncResolve?.({ + stdout, + stderr, + output, + status: 0, + }) + } + } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) { + logForDebugging( + `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`, + ) + } else { + logForDebugging( + `Hooks: Initial response is not async, continuing normal processing`, + ) + } + } catch (e) { + logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`) + } + } + }) + + child.stderr.on('data', data => { + stderr += data + output += data + }) + + const stopProgressInterval = startHookProgressInterval({ + hookId, + hookName, + hookEvent, + getOutput: async () => ({ stdout, stderr, output }), + }) + + // Wait for stdout and stderr streams to finish before considering output complete + // This prevents a race condition where 'close' fires before all 'data' events are processed + const stdoutEndPromise = new Promise(resolve => { + child.stdout.on('end', () => resolve()) + }) + + const stderrEndPromise = new Promise(resolve => { + child.stderr.on('end', () => resolve()) + }) + + // Write to stdin, making sure to handle EPIPE errors that can happen when + // the hook command exits before reading all input. + // Note: EPIPE handling is difficult to set up in testing since Bun and Node + // have different behaviors. + // TODO: Add tests for EPIPE handling. + // Skip if stdin was already written (e.g., by config-based async hook path) + const stdinWritePromise = stdinWritten + ? Promise.resolve() + : new Promise((resolve, reject) => { + child.stdin.on('error', err => { + // When requestPrompt is provided, stdin stays open for prompt responses. + // EPIPE errors from later writes (after process exits) are expected -- suppress them. + if (!requestPrompt) { + reject(err) + } else { + logForDebugging( + `Hooks: stdin error during prompt flow (likely process exited): ${err}`, + ) + } + }) + // Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters + child.stdin.write(jsonInput + '\n', 'utf8') + // When requestPrompt is provided, keep stdin open for prompt responses + if (!requestPrompt) { + child.stdin.end() + } + resolve() + }) + + // Create promise for child process error + const childErrorPromise = new Promise((_, reject) => { + child.on('error', reject) + }) + + // Create promise for child process close - but only resolve after streams end + // to ensure all output has been collected + const childClosePromise = new Promise<{ + stdout: string + stderr: string + output: string + status: number + aborted?: boolean + }>(resolve => { + let exitCode: number | null = null + + child.on('close', code => { + exitCode = code ?? 1 + + // Wait for both streams to end before resolving with the final output + void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => { + // Strip lines we processed as prompt requests so parseHookOutput + // only sees the final hook result. Content-matching against the set + // of actually-processed lines means prompt JSON can never leak + // through (fail-closed), regardless of line positioning. + const finalStdout = + processedPromptLines.size === 0 + ? stdout + : stdout + .split('\n') + .filter(line => !processedPromptLines.has(line.trim())) + .join('\n') + + resolve({ + stdout: finalStdout, + stderr, + output, + status: exitCode!, + aborted: signal.aborted, + }) + }) + }) + }) + + // Race between stdin write, async detection, and process completion + try { + if (shouldEmitDiag) { + logForDiagnosticsNoPII('info', 'hook_spawn_started', { + hook_event_name: hookEvent, + index: hookIndex, + }) + } + await Promise.race([stdinWritePromise, childErrorPromise]) + + // Wait for any pending prompt responses before resolving + const result = await Promise.race([ + childIsAsyncPromise, + childClosePromise, + childErrorPromise, + ]) + // Ensure all queued prompt responses have been sent + await promptChain + diagExitCode = result.status + diagAborted = result.aborted ?? false + return result + } catch (error) { + // Handle errors from stdin write or child process + const code = getErrnoCode(error) + diagExitCode = 1 + + if (code === 'EPIPE') { + logForDebugging( + 'EPIPE error while writing to hook stdin (hook command likely closed early)', + ) + const errMsg = + 'Hook command closed stdin before hook input was fully written (EPIPE)' + return { + stdout: '', + stderr: errMsg, + output: errMsg, + status: 1, + } + } else if (code === 'ABORT_ERR') { + diagAborted = true + return { + stdout: '', + stderr: 'Hook cancelled', + output: 'Hook cancelled', + status: 1, + aborted: true, + } + } else { + const errorMsg = errorMessage(error) + const errOutput = `Error occurred while executing hook command: ${errorMsg}` + return { + stdout: '', + stderr: errOutput, + output: errOutput, + status: 1, + } + } + } finally { + if (shouldEmitDiag) { + logForDiagnosticsNoPII('info', 'hook_spawn_completed', { + hook_event_name: hookEvent, + index: hookIndex, + duration_ms: Date.now() - diagStartMs, + exit_code: diagExitCode, + aborted: diagAborted, + }) + } + stopProgressInterval() + // Clean up stream resources unless ownership was transferred (e.g., to async hook registry) + if (!shellCommandTransferred) { + shellCommand.cleanup() + } + } +} + +/** + * Check if a match query matches a hook matcher pattern + * @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash') + * @param matcher The matcher pattern - can be: + * - Simple string for exact match (e.g., 'Write') + * - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit') + * - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$') + * @returns true if the query matches the pattern + */ +function matchesPattern(matchQuery: string, matcher: string): boolean { + if (!matcher || matcher === '*') { + return true + } + // Check if it's a simple string or pipe-separated list (no regex special chars except |) + if (/^[a-zA-Z0-9_|]+$/.test(matcher)) { + // Handle pipe-separated exact matches + if (matcher.includes('|')) { + const patterns = matcher + .split('|') + .map(p => normalizeLegacyToolName(p.trim())) + return patterns.includes(matchQuery) + } + // Simple exact match + return matchQuery === normalizeLegacyToolName(matcher) + } + + // Otherwise treat as regex + try { + const regex = new RegExp(matcher) + if (regex.test(matchQuery)) { + return true + } + // Also test against legacy names so patterns like "^Task$" still match + for (const legacyName of getLegacyToolNames(matchQuery)) { + if (regex.test(legacyName)) { + return true + } + } + return false + } catch { + // If the regex is invalid, log error and return false + logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`) + return false + } +} + +type IfConditionMatcher = (ifCondition: string) => boolean + +/** + * Prepare a matcher for hook `if` conditions. Expensive work (tool lookup, + * Zod validation, tree-sitter parsing for Bash) happens once here; the + * returned closure is called per hook. Returns undefined for non-tool events. + */ +async function prepareIfConditionMatcher( + hookInput: HookInput, + tools: Tools | undefined, +): Promise { + if ( + hookInput.hook_event_name !== 'PreToolUse' && + hookInput.hook_event_name !== 'PostToolUse' && + hookInput.hook_event_name !== 'PostToolUseFailure' && + hookInput.hook_event_name !== 'PermissionRequest' + ) { + return undefined + } + + const toolName = normalizeLegacyToolName(hookInput.tool_name) + const tool = tools && findToolByName(tools, hookInput.tool_name) + const input = tool?.inputSchema.safeParse(hookInput.tool_input) + const patternMatcher = + input?.success && tool?.preparePermissionMatcher + ? await tool.preparePermissionMatcher(input.data) + : undefined + + return ifCondition => { + const parsed = permissionRuleValueFromString(ifCondition) + if (normalizeLegacyToolName(parsed.toolName) !== toolName) { + return false + } + if (!parsed.ruleContent) { + return true + } + return patternMatcher ? patternMatcher(parsed.ruleContent) : false + } +} + +type FunctionHookMatcher = { + matcher: string + hooks: FunctionHook[] +} + +/** + * A hook paired with optional plugin context. + * Used when returning matched hooks so we can apply plugin env vars at execution time. + */ +type MatchedHook = { + hook: HookCommand | HookCallback | FunctionHook + pluginRoot?: string + pluginId?: string + skillRoot?: string + hookSource?: string +} + +function isInternalHook(matched: MatchedHook): boolean { + return matched.hook.type === 'callback' && matched.hook.internal === true +} + +/** + * Build a dedup key for a matched hook, namespaced by source context. + * + * Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the + * same command defined in user/project/local still collapses to one 鈥?the + * original intent of the dedup. Plugin/skill hooks get their root as the + * prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh` + * template don't collapse: after expansion they point to different files. + */ +function hookDedupKey(m: MatchedHook, payload: string): string { + return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}` +} + +/** + * Build a map of {sanitizedPluginName: hookCount} from matched hooks. + * Only logs actual names for official marketplace plugins; others become 'third-party'. + */ +function getPluginHookCounts( + hooks: MatchedHook[], +): Record | undefined { + const pluginHooks = hooks.filter(h => h.pluginId) + if (pluginHooks.length === 0) { + return undefined + } + const counts: Record = {} + for (const h of pluginHooks) { + const atIndex = h.pluginId!.lastIndexOf('@') + const isOfficial = + atIndex > 0 && + ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1)) + const key = isOfficial ? h.pluginId! : 'third-party' + counts[key] = (counts[key] || 0) + 1 + } + return counts +} + + +/** + * Build a map of {hookType: count} from matched hooks. + */ +function getHookTypeCounts(hooks: MatchedHook[]): Record { + const counts: Record = {} + for (const h of hooks) { + counts[h.hook.type] = (counts[h.hook.type] || 0) + 1 + } + return counts +} + +function getHooksConfig( + appState: AppState | undefined, + sessionId: string, + hookEvent: HookEvent, +): Array< + | HookMatcher + | HookCallbackMatcher + | FunctionHookMatcher + | PluginHookMatcher + | SkillHookMatcher + | SessionDerivedHookMatcher +> { + // HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be + // pushed directly without re-wrapping. + const hooks: Array< + | HookMatcher + | HookCallbackMatcher + | FunctionHookMatcher + | PluginHookMatcher + | SkillHookMatcher + | SessionDerivedHookMatcher + > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])] + + // Check if only managed hooks should run (used for both registered and session hooks) + const managedOnly = shouldAllowManagedHooksOnly() + + // Process registered hooks (SDK callbacks and plugin native hooks) + const registeredHooks = getRegisteredHooks()?.[hookEvent] + if (registeredHooks) { + for (const matcher of registeredHooks) { + // Skip plugin hooks when restricted to managed hooks only + // Plugin hooks have pluginRoot set, SDK callbacks do not + if (managedOnly && 'pluginRoot' in matcher) { + continue + } + hooks.push(matcher) + } + } + + // Merge session hooks for the current session only + // Function hooks (like structured output enforcement) must be scoped to their session + // to prevent hooks from one agent leaking to another (e.g., verification agent to main agent) + // Skip session hooks entirely when allowManagedHooksOnly is set 鈥? + // this prevents frontmatter hooks from agents/skills from bypassing the policy. + // strictPluginOnlyCustomization does NOT block here 鈥?it gates at the + // REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where + // agentDefinition.source is known. A blanket block here would also kill + // plugin-provided agents' frontmatter hooks, which is too broad. + // Also skip if appState not provided (for backwards compatibility) + if (!managedOnly && appState !== undefined) { + const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get( + hookEvent, + ) + if (sessionHooks) { + // SessionDerivedHookMatcher already includes optional skillRoot + for (const matcher of sessionHooks) { + hooks.push(matcher) + } + } + + // Merge session function hooks separately (can't be persisted to HookMatcher format) + const sessionFunctionHooks = getSessionFunctionHooks( + appState, + sessionId, + hookEvent, + ).get(hookEvent) + if (sessionFunctionHooks) { + for (const matcher of sessionFunctionHooks) { + hooks.push(matcher) + } + } + } + + return hooks +} + +/** + * Lightweight existence check for hooks on a given event. Mirrors the sources + * assembled by getHooksConfig() but stops at the first hit without building + * the full merged config. + * + * Intentionally over-approximates: returns true if any matcher exists for the + * event, even if managed-only filtering or pattern matching would later + * discard it. A false positive just means we proceed to the full matching + * path; a false negative would skip a hook, so we err on the side of true. + * + * Used to skip createBaseHookInput (getTranscriptPathForSession path joins) + * and getMatchingHooks on hot paths where hooks are typically unconfigured. + * See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern. + */ +function hasHookForEvent( + hookEvent: HookEvent, + appState: AppState | undefined, + sessionId: string, +): boolean { + const snap = getHooksConfigFromSnapshot()?.[hookEvent] + if (snap && snap.length > 0) return true + const reg = getRegisteredHooks()?.[hookEvent] + if (reg && reg.length > 0) return true + if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true + return false +} + +/** + * Get hook commands that match the given query + * @param appState The current app state (optional for backwards compatibility) + * @param sessionId The current session ID (main session or agent ID) + * @param hookEvent The hook event + * @param hookInput The hook input for matching + * @returns Array of matched hooks with optional plugin context + */ +export async function getMatchingHooks( + appState: AppState | undefined, + sessionId: string, + hookEvent: HookEvent, + hookInput: HookInput, + tools?: Tools, +): Promise { + try { + const hookMatchers = getHooksConfig(appState, sessionId, hookEvent) + + // If you change the criteria below, then you must change + // src/utils/hooks/hooksConfigManager.ts as well. + let matchQuery: string | undefined = undefined + switch (hookInput.hook_event_name) { + case 'PreToolUse': + case 'PostToolUse': + case 'PostToolUseFailure': + case 'PermissionRequest': + case 'PermissionDenied': + matchQuery = hookInput.tool_name + break + case 'SessionStart': + matchQuery = hookInput.source + break + case 'Setup': + matchQuery = hookInput.trigger + break + case 'PreCompact': + case 'PostCompact': + matchQuery = hookInput.trigger + break + case 'Notification': + matchQuery = hookInput.notification_type + break + case 'SessionEnd': + matchQuery = hookInput.reason + break + case 'StopFailure': + matchQuery = hookInput.error + break + case 'SubagentStart': + matchQuery = hookInput.agent_type + break + case 'SubagentStop': + matchQuery = hookInput.agent_type + break + case 'TeammateIdle': + case 'TaskCreated': + case 'TaskCompleted': + break + case 'Elicitation': + matchQuery = hookInput.mcp_server_name + break + case 'ElicitationResult': + matchQuery = hookInput.mcp_server_name + break + case 'ConfigChange': + matchQuery = hookInput.source + break + case 'InstructionsLoaded': + matchQuery = hookInput.load_reason + break + case 'FileChanged': + matchQuery = basename(hookInput.file_path) + break + default: + break + } + + logForDebugging( + `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`, + { level: 'verbose' }, + ) + logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, { + level: 'verbose', + }) + + // Extract hooks with their plugin context (if any) + const filteredMatchers = matchQuery + ? hookMatchers.filter( + matcher => + !matcher.matcher || matchesPattern(matchQuery, matcher.matcher), + ) + : hookMatchers + + const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => { + // Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot) + const pluginRoot = + 'pluginRoot' in matcher ? matcher.pluginRoot : undefined + const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined + const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined + const hookSource = pluginRoot + ? 'pluginName' in matcher + ? `plugin:${matcher.pluginName}` + : 'plugin' + : skillRoot + ? 'skillName' in matcher + ? `skill:${matcher.skillName}` + : 'skill' + : 'settings' + return matcher.hooks.map(hook => ({ + hook, + pluginRoot, + pluginId, + skillRoot, + hookSource, + })) + }) + + // Deduplicate hooks by command/prompt/url within the same source context. + // Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so + // cross-plugin template collisions don't drop hooks (gh-29724). + // + // Note: new Map(entries) keeps the LAST entry on key collision, not first. + // For settings hooks this means the last-merged scope wins; for + // same-plugin duplicates the pluginRoot is identical so it doesn't matter. + // Fast-path: callback/function hooks don't need dedup (each is unique). + // Skip the 6-pass filter + 4脳Map + 4脳Array.from below when all hooks are + // callback/function 鈥?the common case for internal hooks like + // sessionFileAccessHooks/attributionHooks (44x faster in microbench). + if ( + matchedHooks.every( + m => m.hook.type === 'callback' || m.hook.type === 'function', + ) + ) { + return matchedHooks + } + + // Helper to extract the `if` condition from a hook for dedup keys. + // Hooks with different `if` conditions are distinct even if otherwise identical. + const getIfCondition = (hook: { if?: string }): string => hook.if ?? '' + + const uniqueCommandHooks = Array.from( + new Map( + matchedHooks + .filter( + ( + m, + ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } => + m.hook.type === 'command', + ) + // shell is part of identity: {command:'echo x', shell:'bash'} + // and {command:'echo x', shell:'powershell'} are distinct hooks, + // not duplicates. Default to 'bash' so legacy configs (no shell + // field) still dedup against explicit shell:'bash'. + .map(m => [ + hookDedupKey( + m, + `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`, + ), + m, + ]), + ).values(), + ) + const uniquePromptHooks = Array.from( + new Map( + matchedHooks + .filter(m => m.hook.type === 'prompt') + .map(m => [ + hookDedupKey( + m, + `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, + ), + m, + ]), + ).values(), + ) + const uniqueAgentHooks = Array.from( + new Map( + matchedHooks + .filter(m => m.hook.type === 'agent') + .map(m => [ + hookDedupKey( + m, + `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, + ), + m, + ]), + ).values(), + ) + const uniqueHttpHooks = Array.from( + new Map( + matchedHooks + .filter(m => m.hook.type === 'http') + .map(m => [ + hookDedupKey( + m, + `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`, + ), + m, + ]), + ).values(), + ) + const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback') + // Function hooks don't need deduplication - each callback is unique + const functionHooks = matchedHooks.filter(m => m.hook.type === 'function') + const uniqueHooks = [ + ...uniqueCommandHooks, + ...uniquePromptHooks, + ...uniqueAgentHooks, + ...uniqueHttpHooks, + ...callbackHooks, + ...functionHooks, + ] + + // Filter hooks based on their `if` condition. This allows hooks to specify + // conditions like "Bash(git *)" to only run for git commands, avoiding + // process spawning overhead for non-matching commands. + const hasIfCondition = uniqueHooks.some( + h => + (h.hook.type === 'command' || + h.hook.type === 'prompt' || + h.hook.type === 'agent' || + h.hook.type === 'http') && + (h.hook as { if?: string }).if, + ) + const ifMatcher = hasIfCondition + ? await prepareIfConditionMatcher(hookInput, tools) + : undefined + const ifFilteredHooks = uniqueHooks.filter(h => { + if ( + h.hook.type !== 'command' && + h.hook.type !== 'prompt' && + h.hook.type !== 'agent' && + h.hook.type !== 'http' + ) { + return true + } + const ifCondition = (h.hook as { if?: string }).if + if (!ifCondition) { + return true + } + if (!ifMatcher) { + logForDebugging( + `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`, + ) + return false + } + if (ifMatcher(ifCondition)) { + return true + } + logForDebugging( + `Skipping hook due to if condition "${ifCondition}" not matching`, + ) + return false + }) + + // HTTP hooks are not supported for SessionStart/Setup events. In headless + // mode the sandbox ask callback deadlocks because the structuredInput + // consumer hasn't started yet when these hooks fire. + const filteredHooks = + hookEvent === 'SessionStart' || hookEvent === 'Setup' + ? ifFilteredHooks.filter(h => { + if (h.hook.type === 'http') { + logForDebugging( + `Skipping HTTP hook ${(h.hook as { url: string }).url} 鈥?HTTP hooks are not supported for ${hookEvent}`, + ) + return false + } + return true + }) + : ifFilteredHooks + + logForDebugging( + `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`, + { level: 'verbose' }, + ) + return filteredHooks + } catch { + return [] + } +} + +/** + * Format a list of blocking errors from a PreTool hook's configured commands. + * @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash') + * @param blockingErrors Array of blocking errors from hooks + * @returns Formatted blocking message + */ +export function getPreToolHookBlockingMessage( + hookName: string, + blockingError: HookBlockingError, +): string { + return `${hookName} hook error: ${blockingError.blockingError}` +} + +/** + * Format a list of blocking errors from a Stop hook's configured commands. + * @param blockingErrors Array of blocking errors from hooks + * @returns Formatted message to give feedback to the model + */ +export function getStopHookMessage(blockingError: HookBlockingError): string { + return `Stop hook feedback:\n${blockingError.blockingError}` +} + +/** + * Format a blocking error from a TeammateIdle hook. + * @param blockingError The blocking error from the hook + * @returns Formatted message to give feedback to the model + */ +export function getTeammateIdleHookMessage( + blockingError: HookBlockingError, +): string { + return `TeammateIdle hook feedback:\n${blockingError.blockingError}` +} + +/** + * Format a blocking error from a TaskCreated hook. + * @param blockingError The blocking error from the hook + * @returns Formatted message to give feedback to the model + */ +export function getTaskCreatedHookMessage( + blockingError: HookBlockingError, +): string { + return `TaskCreated hook feedback:\n${blockingError.blockingError}` +} + +/** + * Format a blocking error from a TaskCompleted hook. + * @param blockingError The blocking error from the hook + * @returns Formatted message to give feedback to the model + */ +export function getTaskCompletedHookMessage( + blockingError: HookBlockingError, +): string { + return `TaskCompleted hook feedback:\n${blockingError.blockingError}` +} + +/** + * Format a list of blocking errors from a UserPromptSubmit hook's configured commands. + * @param blockingErrors Array of blocking errors from hooks + * @returns Formatted blocking message + */ +export function getUserPromptSubmitHookBlockingMessage( + blockingError: HookBlockingError, +): string { + return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}` +} +/** + * Common logic for executing hooks + * @param hookInput The structured hook input that will be validated and converted to JSON + * @param toolUseID The ID for tracking this hook execution + * @param matchQuery The query to match against hook matchers + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks) + * @param messages Optional conversation history for prompt/function hooks + * @returns Async generator that yields progress messages and hook results + */ +async function* executeHooks({ + hookInput, + toolUseID, + matchQuery, + signal, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + toolUseContext, + messages, + forceSyncExecution, + requestPrompt, + toolInputSummary, +}: { + hookInput: HookInput + toolUseID: string + matchQuery?: string + signal?: AbortSignal + timeoutMs?: number + toolUseContext?: ToolUseContext + messages?: Message[] + forceSyncExecution?: boolean + requestPrompt?: ( + sourceName: string, + toolInputSummary?: string | null, + ) => (request: PromptRequest) => Promise + toolInputSummary?: string | null +}): AsyncGenerator { + if (shouldDisableAllHooksIncludingManaged()) { + return + } + + if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { + return + } + + const hookEvent = hookInput.hook_event_name + const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent + + // Bind the prompt callback to this hook's name and tool input summary so the UI can display context + const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary) + + // SECURITY: ALL hooks require workspace trust in interactive mode + // This centralized check prevents RCE vulnerabilities for all current and future hooks + if (shouldSkipHookDueToTrust()) { + logForDebugging( + `Skipping ${hookName} hook execution - workspace trust not accepted`, + ) + return + } + + const appState = toolUseContext ? toolUseContext.getAppState() : undefined + // Use the agent's session ID if available, otherwise fall back to main session + const sessionId = toolUseContext?.agentId ?? getSessionId() + const matchingHooks = await getMatchingHooks( + appState, + sessionId, + hookEvent, + hookInput, + toolUseContext?.options?.tools, + ) + if (matchingHooks.length === 0) { + return + } + + if (signal?.aborted) { + return + } + + const userHooks = matchingHooks.filter(h => !isInternalHook(h)) + if (userHooks.length > 0) { + const pluginHookCounts = getPluginHookCounts(userHooks) + const hookTypeCounts = getHookTypeCounts(userHooks) + logEvent(`tengu_run_hook`, { + hookName: + hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numCommands: userHooks.length, + hookTypeCounts: jsonStringify( + hookTypeCounts, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(pluginHookCounts && { + pluginHookCounts: jsonStringify( + pluginHookCounts, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }) + } else { + // Fast-path: all hooks are internal callbacks (sessionFileAccessHooks, + // attributionHooks). These return {} and don't use the abort signal, so we + // can skip span/progress/abortSignal/processHookJSONOutput/resultLoop. + // Measured: 6.01碌s 鈫?~1.8碌s per PostToolUse hit (-70%). + const batchStartTime = Date.now() + const context = toolUseContext + ? { + getAppState: toolUseContext.getAppState, + updateAttributionState: toolUseContext.updateAttributionState, + } + : undefined + for (const [i, { hook }] of matchingHooks.entries()) { + if (hook.type === 'callback') { + await hook.callback(hookInput, toolUseID, signal, i, context) + } + } + const totalDurationMs = Date.now() - batchStartTime + getStatsStore()?.observe('hook_duration_ms', totalDurationMs) + addToTurnHookDuration(totalDurationMs) + logEvent(`tengu_repl_hook_finished`, { + hookName: + hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numCommands: matchingHooks.length, + numSuccess: matchingHooks.length, + numBlocking: 0, + numNonBlockingError: 0, + numCancelled: 0, + totalDurationMs, + }) + return + } + + // Collect hook definitions for beta tracing telemetry + const hookDefinitionsJson = isBetaTracingEnabled() + ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks)) + : '[]' + + // Log hook execution start to OTEL (only for beta tracing) + if (isBetaTracingEnabled()) { + void logOTelEvent('hook_execution_start', { + hook_event: hookEvent, + hook_name: hookName, + num_hooks: String(matchingHooks.length), + managed_only: String(shouldAllowManagedHooksOnly()), + hook_definitions: hookDefinitionsJson, + hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', + }) + } + + // Start hook span for beta tracing + const hookSpan = startHookSpan( + hookEvent, + hookName, + matchingHooks.length, + hookDefinitionsJson, + ) + + // Yield progress messages for each hook before execution + for (const { hook } of matchingHooks) { + yield { + message: { + type: 'progress', + data: { + type: 'hook_progress', + hookEvent, + hookName, + command: getHookDisplayText(hook), + ...(hook.type === 'prompt' && { promptText: hook.prompt }), + ...('statusMessage' in hook && + hook.statusMessage != null && { + statusMessage: hook.statusMessage, + }), + }, + parentToolUseID: toolUseID, + toolUseID, + timestamp: new Date().toISOString(), + uuid: randomUUID(), + }, + } + } + + // Track wall-clock time for the entire hook batch + const batchStartTime = Date.now() + + // Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http + // hooks in this batch (hookInput is never mutated). Callback/function hooks + // return before reaching this, so batches with only those pay no stringify cost. + let jsonInputResult: + | { ok: true; value: string } + | { ok: false; error: unknown } + | undefined + function getJsonInput() { + if (jsonInputResult !== undefined) { + return jsonInputResult + } + try { + return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) }) + } catch (error) { + logError( + Error(`Failed to stringify hook ${hookName} input`, { cause: error }), + ) + return (jsonInputResult = { ok: false, error }) + } + } + + // Run all hooks in parallel with individual timeouts + const hookPromises = matchingHooks.map(async function* ( + { hook, pluginRoot, pluginId, skillRoot }, + hookIndex, + ): AsyncGenerator { + if (hook.type === 'callback') { + const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs + const { signal: abortSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs: callbackTimeoutMs }, + ) + yield executeHookCallback({ + toolUseID, + hook, + hookEvent, + hookInput, + signal: abortSignal, + hookIndex, + toolUseContext, + }).finally(cleanup) + return + } + + if (hook.type === 'function') { + if (!messages) { + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + hookName, + toolUseID, + hookEvent, + content: 'Messages not provided for function hook', + }), + outcome: 'non_blocking_error', + hook, + } + return + } + + // Function hooks only come from session storage with callback embedded + yield executeFunctionHook({ + hook, + messages, + hookName, + toolUseID, + hookEvent, + timeoutMs, + signal, + }) + return + } + + // Command and prompt hooks need jsonInput + const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs + const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { + timeoutMs: commandTimeoutMs, + }) + const hookId = randomUUID() + const hookStartMs = Date.now() + const hookCommand = getHookDisplayText(hook) + + try { + const jsonInputRes = getJsonInput() + if (!jsonInputRes.ok) { + yield { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + hookName, + toolUseID, + hookEvent, + content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`, + command: hookCommand, + durationMs: Date.now() - hookStartMs, + }), + outcome: 'non_blocking_error', + hook, + } + cleanup() + return + } + const jsonInput = jsonInputRes.value + + if (hook.type === 'prompt') { + if (!toolUseContext) { + throw new Error( + 'ToolUseContext is required for prompt hooks. This is a bug.', + ) + } + const promptResult = await execPromptHook( + hook, + hookName, + hookEvent, + jsonInput, + abortSignal, + toolUseContext, + messages, + toolUseID, + ) + // Inject timing fields for hook visibility + if (promptResult.message?.type === 'attachment') { + const att = promptResult.message.attachment + if ( + att.type === 'hook_success' || + att.type === 'hook_non_blocking_error' + ) { + att.command = hookCommand + att.durationMs = Date.now() - hookStartMs + } + } + yield promptResult + cleanup?.() + return + } + + if (hook.type === 'agent') { + if (!toolUseContext) { + throw new Error( + 'ToolUseContext is required for agent hooks. This is a bug.', + ) + } + if (!messages) { + throw new Error( + 'Messages are required for agent hooks. This is a bug.', + ) + } + const agentResult = await execAgentHook( + hook, + hookName, + hookEvent, + jsonInput, + abortSignal, + toolUseContext, + toolUseID, + messages, + 'agent_type' in hookInput + ? (hookInput.agent_type as string) + : undefined, + ) + // Inject timing fields for hook visibility + if (agentResult.message?.type === 'attachment') { + const att = agentResult.message.attachment + if ( + att.type === 'hook_success' || + att.type === 'hook_non_blocking_error' + ) { + att.command = hookCommand + att.durationMs = Date.now() - hookStartMs + } + } + yield agentResult + cleanup?.() + return + } + + if (hook.type === 'http') { + emitHookStarted(hookId, hookName, hookEvent) + + // execHttpHook manages its own timeout internally via hook.timeout or + // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly + // to avoid double-stacking timeouts with abortSignal. + const httpResult = await execHttpHook( + hook, + hookEvent, + jsonInput, + signal, + ) + cleanup?.() + + if (httpResult.aborted) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: 'Hook cancelled', + stdout: '', + stderr: '', + exitCode: undefined, + outcome: 'cancelled', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName, + toolUseID, + hookEvent, + }), + outcome: 'cancelled' as const, + hook, + } + return + } + + if (httpResult.error || !httpResult.ok) { + const stderr = + httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: stderr, + stdout: '', + stderr, + exitCode: httpResult.statusCode, + outcome: 'error', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID, + hookEvent, + stderr, + stdout: '', + exitCode: httpResult.statusCode ?? 0, + }), + outcome: 'non_blocking_error' as const, + hook, + } + return + } + + // HTTP hooks must return JSON 鈥?parse and validate through Zod + const { json: httpJson, validationError: httpValidationError } = + parseHttpHookOutput(httpResult.body) + + if (httpValidationError) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: httpResult.body, + stdout: httpResult.body, + stderr: `JSON validation failed: ${httpValidationError}`, + exitCode: httpResult.statusCode, + outcome: 'error', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID, + hookEvent, + stderr: `JSON validation failed: ${httpValidationError}`, + stdout: httpResult.body, + exitCode: httpResult.statusCode ?? 0, + }), + outcome: 'non_blocking_error' as const, + hook, + } + return + } + + if (httpJson && isAsyncHookJSONOutput(httpJson)) { + // Async response: treat as success (no further processing) + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: httpResult.body, + stdout: httpResult.body, + stderr: '', + exitCode: httpResult.statusCode, + outcome: 'success', + }) + yield { + outcome: 'success' as const, + hook, + } + return + } + + if (httpJson) { + const processed = processHookJSONOutput({ + json: httpJson, + command: hook.url, + hookName, + toolUseID, + hookEvent, + expectedHookEvent: hookEvent, + stdout: httpResult.body, + stderr: '', + exitCode: httpResult.statusCode, + }) + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: httpResult.body, + stdout: httpResult.body, + stderr: '', + exitCode: httpResult.statusCode, + outcome: 'success', + }) + yield { + ...processed, + outcome: 'success' as const, + hook, + } + return + } + + return + } + + emitHookStarted(hookId, hookName, hookEvent) + + const result = await execCommandHook( + hook, + hookEvent, + hookName, + jsonInput, + abortSignal, + hookId, + hookIndex, + pluginRoot, + pluginId, + skillRoot, + forceSyncExecution, + boundRequestPrompt, + ) + cleanup?.() + const durationMs = Date.now() - hookStartMs + + if (result.backgrounded) { + yield { + outcome: 'success' as const, + hook, + } + return + } + + if (result.aborted) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: 'cancelled', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_cancelled', + hookName, + toolUseID, + hookEvent, + command: hookCommand, + durationMs, + }), + outcome: 'cancelled' as const, + hook, + } + return + } + + // Try JSON parsing first + const { json, plainText, validationError } = parseHookOutput( + result.stdout, + ) + + if (validationError) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: `JSON validation failed: ${validationError}`, + exitCode: 1, + outcome: 'error', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID, + hookEvent, + stderr: `JSON validation failed: ${validationError}`, + stdout: result.stdout, + exitCode: 1, + command: hookCommand, + durationMs, + }), + outcome: 'non_blocking_error' as const, + hook, + } + return + } + + if (json) { + // Async responses were already backgrounded during execution + if (isAsyncHookJSONOutput(json)) { + yield { + outcome: 'success' as const, + hook, + } + return + } + + // Process JSON output + const processed = processHookJSONOutput({ + json, + command: hookCommand, + hookName, + toolUseID, + hookEvent, + expectedHookEvent: hookEvent, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + durationMs, + }) + + // Handle suppressOutput (skip for async responses) + if ( + isSyncHookJSONOutput(json) && + !json.suppressOutput && + plainText && + result.status === 0 + ) { + // Still show non-JSON output if not suppressed + const content = `${chalk.bold(hookName)} completed` + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: 'success', + }) + yield { + ...processed, + message: + processed.message || + createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID, + hookEvent, + content, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + command: hookCommand, + durationMs, + }), + outcome: 'success' as const, + hook, + } + return + } + + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: result.status === 0 ? 'success' : 'error', + }) + yield { + ...processed, + outcome: 'success' as const, + hook, + } + return + } + + // Fall back to existing logic for non-JSON output + if (result.status === 0) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: 'success', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID, + hookEvent, + content: result.stdout.trim(), + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + command: hookCommand, + durationMs, + }), + outcome: 'success' as const, + hook, + } + return + } + + // Hooks with exit code 2 provide blocking feedback + if (result.status === 2) { + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: 'error', + }) + yield { + blockingError: { + blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`, + command: hook.command, + }, + outcome: 'blocking' as const, + hook, + } + return + } + + // Any other non-zero exit code is a non-critical error that should just + // be shown to the user. + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: result.output, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.status, + outcome: 'error', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID, + hookEvent, + stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`, + stdout: result.stdout, + exitCode: result.status, + command: hookCommand, + durationMs, + }), + outcome: 'non_blocking_error' as const, + hook, + } + return + } catch (error) { + // Clean up on error + cleanup?.() + + const errorMessage = + error instanceof Error ? error.message : String(error) + emitHookResponse({ + hookId, + hookName, + hookEvent, + output: `Failed to run: ${errorMessage}`, + stdout: '', + stderr: `Failed to run: ${errorMessage}`, + exitCode: 1, + outcome: 'error', + }) + yield { + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID, + hookEvent, + stderr: `Failed to run: ${errorMessage}`, + stdout: '', + exitCode: 1, + command: hookCommand, + durationMs: Date.now() - hookStartMs, + }), + outcome: 'non_blocking_error' as const, + hook, + } + return + } + }) + + // Track outcomes for logging + const outcomes = { + success: 0, + blocking: 0, + non_blocking_error: 0, + cancelled: 0, + } + + let permissionBehavior: PermissionResult['behavior'] | undefined + + // Run all hooks in parallel and wait for all to complete + for await (const result of all(hookPromises)) { + outcomes[result.outcome]++ + + // Check for preventContinuation early + if (result.preventContinuation) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`, + ) + yield { + preventContinuation: true, + blockingError: result.blockingError, + stopReason: result.stopReason, + } + } + + // Handle different result types + if (result.blockingError) { + yield { + blockingError: result.blockingError, + } + } + + if (result.message) { + yield { message: result.message } + } + + // Yield system message separately if present + if (result.systemMessage) { + yield { + message: createAttachmentMessage({ + type: 'hook_system_message', + content: result.systemMessage, + hookName, + toolUseID, + hookEvent, + }), + } + } + + // Collect additional context from hooks + if (result.additionalContext) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`, + ) + yield { + additionalContexts: [result.additionalContext], + } + } + + if (result.initialUserMessage) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`, + ) + yield { + initialUserMessage: result.initialUserMessage, + } + } + + if (result.watchPaths && result.watchPaths.length > 0) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`, + ) + yield { + watchPaths: result.watchPaths, + } + } + + // Yield updatedMCPToolOutput if provided (from PostToolUse hooks) + if (result.updatedMCPToolOutput) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`, + ) + yield { + updatedMCPToolOutput: result.updatedMCPToolOutput, + } + } + + // Check for permission behavior with precedence: deny > ask > allow + if (result.permissionBehavior) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`, + ) + // Apply precedence rules + switch (result.permissionBehavior) { + case 'deny': + // deny always takes precedence + permissionBehavior = 'deny' + break + case 'ask': + // ask takes precedence over allow but not deny + if (permissionBehavior !== 'deny') { + permissionBehavior = 'ask' + } + break + case 'allow': + // allow only if no other behavior set + if (!permissionBehavior) { + permissionBehavior = 'allow' + } + break + case 'passthrough': + // passthrough doesn't set permission behavior + break + } + } + + // Yield permission behavior and updatedInput if provided (from allow or ask behavior) + if (permissionBehavior !== undefined) { + const updatedInput = + result.updatedInput && + (result.permissionBehavior === 'allow' || + result.permissionBehavior === 'ask') + ? result.updatedInput + : undefined + if (updatedInput) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`, + ) + } + yield { + permissionBehavior, + hookPermissionDecisionReason: result.hookPermissionDecisionReason, + hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource, + updatedInput, + } + } + + // Yield updatedInput separately for passthrough case (no permission decision) + // This allows hooks to modify input without making a permission decision + // Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior + if (result.updatedInput && result.permissionBehavior === undefined) { + logForDebugging( + `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`, + ) + yield { + updatedInput: result.updatedInput, + } + } + // Yield permission request result if provided (from PermissionRequest hooks) + if (result.permissionRequestResult) { + yield { + permissionRequestResult: result.permissionRequestResult, + } + } + // Yield retry flag if provided (from PermissionDenied hooks) + if (result.retry) { + yield { + retry: result.retry, + } + } + // Yield elicitation response if provided (from Elicitation hooks) + if (result.elicitationResponse) { + yield { + elicitationResponse: result.elicitationResponse, + } + } + // Yield elicitation result response if provided (from ElicitationResult hooks) + if (result.elicitationResultResponse) { + yield { + elicitationResultResponse: result.elicitationResultResponse, + } + } + + // Invoke session hook callback if this is a command/prompt/function hook (not a callback hook) + if (appState && result.hook.type !== 'callback') { + const sessionId = getSessionId() + // Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks) + const matcher = matchQuery ?? '' + const hookEntry = getSessionHookCallback( + appState, + sessionId, + hookEvent, + matcher, + result.hook, + ) + // Invoke onHookSuccess only on success outcome + if (hookEntry?.onHookSuccess && result.outcome === 'success') { + try { + hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult) + } catch (error) { + logError( + Error('Session hook success callback failed', { cause: error }), + ) + } + } + } + } + + const totalDurationMs = Date.now() - batchStartTime + getStatsStore()?.observe('hook_duration_ms', totalDurationMs) + addToTurnHookDuration(totalDurationMs) + + logEvent(`tengu_repl_hook_finished`, { + hookName: + hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numCommands: matchingHooks.length, + numSuccess: outcomes.success, + numBlocking: outcomes.blocking, + numNonBlockingError: outcomes.non_blocking_error, + numCancelled: outcomes.cancelled, + totalDurationMs, + }) + + // Log hook execution completion to OTEL (only for beta tracing) + if (isBetaTracingEnabled()) { + const hookDefinitionsComplete = + getHookDefinitionsForTelemetry(matchingHooks) + + void logOTelEvent('hook_execution_complete', { + hook_event: hookEvent, + hook_name: hookName, + num_hooks: String(matchingHooks.length), + num_success: String(outcomes.success), + num_blocking: String(outcomes.blocking), + num_non_blocking_error: String(outcomes.non_blocking_error), + num_cancelled: String(outcomes.cancelled), + managed_only: String(shouldAllowManagedHooksOnly()), + hook_definitions: jsonStringify(hookDefinitionsComplete), + hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', + }) + } + + // End hook span for beta tracing + endHookSpan(hookSpan, { + numSuccess: outcomes.success, + numBlocking: outcomes.blocking, + numNonBlockingError: outcomes.non_blocking_error, + numCancelled: outcomes.cancelled, + }) +} + +export type HookOutsideReplResult = { + command: string + succeeded: boolean + output: string + blocked: boolean + watchPaths?: string[] + systemMessage?: string +} + +export function hasBlockingResult(results: HookOutsideReplResult[]): boolean { + return results.some(r => r.blocked) +} + +/** + * Execute hooks outside of the REPL (e.g. notifications, session end) + * + * Unlike executeHooks() which yields messages that are exposed to the model as + * system messages, this function only logs errors via logForDebugging (visible + * with --debug). Callers that need to surface errors to users should handle + * the returned results appropriately (e.g. executeSessionEndHooks writes to + * stderr during shutdown). + * + * @param getAppState Optional function to get the current app state (for session hooks) + * @param hookInput The structured hook input that will be validated and converted to JSON + * @param matchQuery The query to match against hook matchers + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output + */ +async function executeHooksOutsideREPL({ + getAppState, + hookInput, + matchQuery, + signal, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +}: { + getAppState?: () => AppState + hookInput: HookInput + matchQuery?: string + signal?: AbortSignal + timeoutMs: number +}): Promise { + if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { + return [] + } + + const hookEvent = hookInput.hook_event_name + const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent + if (shouldDisableAllHooksIncludingManaged()) { + logForDebugging( + `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, + ) + return [] + } + + // SECURITY: ALL hooks require workspace trust in interactive mode + // This centralized check prevents RCE vulnerabilities for all current and future hooks + if (shouldSkipHookDueToTrust()) { + logForDebugging( + `Skipping ${hookName} hook execution - workspace trust not accepted`, + ) + return [] + } + + const appState = getAppState ? getAppState() : undefined + // Use main session ID for outside-REPL hooks + const sessionId = getSessionId() + const matchingHooks = await getMatchingHooks( + appState, + sessionId, + hookEvent, + hookInput, + ) + if (matchingHooks.length === 0) { + return [] + } + + if (signal?.aborted) { + return [] + } + + const userHooks = matchingHooks.filter(h => !isInternalHook(h)) + if (userHooks.length > 0) { + const pluginHookCounts = getPluginHookCounts(userHooks) + const hookTypeCounts = getHookTypeCounts(userHooks) + logEvent(`tengu_run_hook`, { + hookName: + hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numCommands: userHooks.length, + hookTypeCounts: jsonStringify( + hookTypeCounts, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(pluginHookCounts && { + pluginHookCounts: jsonStringify( + pluginHookCounts, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }) + } + + // Validate and stringify the hook input + let jsonInput: string + try { + jsonInput = jsonStringify(hookInput) + } catch (error) { + logError(error) + return [] + } + + // Run all hooks in parallel with individual timeouts + const hookPromises = matchingHooks.map( + async ({ hook, pluginRoot, pluginId }, hookIndex) => { + // Handle callback hooks + if (hook.type === 'callback') { + const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs + const { signal: abortSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs: callbackTimeoutMs }, + ) + + try { + const toolUseID = randomUUID() + const json = await hook.callback( + hookInput, + toolUseID, + abortSignal, + hookIndex, + ) + + cleanup?.() + + if (isAsyncHookJSONOutput(json)) { + logForDebugging( + `${hookName} [callback] returned async response, returning empty output`, + ) + return { + command: 'callback', + succeeded: true, + output: '', + blocked: false, + } + } + + const output = + hookEvent === 'WorktreeCreate' && + isSyncHookJSONOutput(json) && + json.hookSpecificOutput?.hookEventName === 'WorktreeCreate' + ? json.hookSpecificOutput.worktreePath + : json.systemMessage || '' + const blocked = + isSyncHookJSONOutput(json) && json.decision === 'block' + + logForDebugging(`${hookName} [callback] completed successfully`) + + return { + command: 'callback', + succeeded: true, + output, + blocked, + } + } catch (error) { + cleanup?.() + + const errorMessage = + error instanceof Error ? error.message : String(error) + logForDebugging( + `${hookName} [callback] failed to run: ${errorMessage}`, + { level: 'error' }, + ) + return { + command: 'callback', + succeeded: false, + output: errorMessage, + blocked: false, + } + } + } + + // TODO: Implement prompt stop hooks outside REPL + if (hook.type === 'prompt') { + return { + command: hook.prompt, + succeeded: false, + output: 'Prompt stop hooks are not yet supported outside REPL', + blocked: false, + } + } + + // TODO: Implement agent stop hooks outside REPL + if (hook.type === 'agent') { + return { + command: hook.prompt, + succeeded: false, + output: 'Agent stop hooks are not yet supported outside REPL', + blocked: false, + } + } + + // Function hooks require messages array (only available in REPL context) + // For -p mode Stop hooks, use executeStopHooks which supports function hooks + if (hook.type === 'function') { + logError( + new Error( + `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`, + ), + ) + return { + command: 'function', + succeeded: false, + output: 'Internal error: function hook executed outside REPL context', + blocked: false, + } + } + + // Handle HTTP hooks (no toolUseContext needed - just HTTP POST). + // execHttpHook handles its own timeout internally via hook.timeout or + // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly. + if (hook.type === 'http') { + try { + const httpResult = await execHttpHook( + hook, + hookEvent, + jsonInput, + signal, + ) + + if (httpResult.aborted) { + logForDebugging(`${hookName} [${hook.url}] cancelled`) + return { + command: hook.url, + succeeded: false, + output: 'Hook cancelled', + blocked: false, + } + } + + if (httpResult.error || !httpResult.ok) { + const errMsg = + httpResult.error || + `HTTP ${httpResult.statusCode} from ${hook.url}` + logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, { + level: 'error', + }) + return { + command: hook.url, + succeeded: false, + output: errMsg, + blocked: false, + } + } + + // HTTP hooks must return JSON 鈥?parse and validate through Zod + const { json: httpJson, validationError: httpValidationError } = + parseHttpHookOutput(httpResult.body) + if (httpValidationError) { + throw new Error(httpValidationError) + } + if (httpJson && !isAsyncHookJSONOutput(httpJson)) { + logForDebugging( + `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`, + { level: 'verbose' }, + ) + } + const jsonBlocked = + httpJson && + !isAsyncHookJSONOutput(httpJson) && + isSyncHookJSONOutput(httpJson) && + httpJson.decision === 'block' + + // WorktreeCreate's consumer reads `output` as the bare filesystem + // path. Command hooks provide it via stdout; http hooks provide it + // via hookSpecificOutput.worktreePath. Without worktreePath, emit '' + // so the consumer's length filter skips it instead of treating the + // raw '{}' body as a path. + const output = + hookEvent === 'WorktreeCreate' + ? httpJson && + isSyncHookJSONOutput(httpJson) && + httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate' + ? httpJson.hookSpecificOutput.worktreePath + : '' + : httpResult.body + + return { + command: hook.url, + succeeded: true, + output, + blocked: !!jsonBlocked, + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error) + logForDebugging( + `${hookName} [${hook.url}] failed to run: ${errorMessage}`, + { level: 'error' }, + ) + return { + command: hook.url, + succeeded: false, + output: errorMessage, + blocked: false, + } + } + } + + // Handle command hooks + const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs + const { signal: abortSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs: commandTimeoutMs }, + ) + try { + const result = await execCommandHook( + hook, + hookEvent, + hookName, + jsonInput, + abortSignal, + randomUUID(), + hookIndex, + pluginRoot, + pluginId, + ) + + // Clear timeout if hook completes + cleanup?.() + + if (result.aborted) { + logForDebugging(`${hookName} [${hook.command}] cancelled`) + return { + command: hook.command, + succeeded: false, + output: 'Hook cancelled', + blocked: false, + } + } + + logForDebugging( + `${hookName} [${hook.command}] completed with status ${result.status}`, + ) + + // Parse JSON for any messages to print out. + const { json, validationError } = parseHookOutput(result.stdout) + if (validationError) { + // Validation error is logged via logForDebugging and returned in output + throw new Error(validationError) + } + if (json && !isAsyncHookJSONOutput(json)) { + logForDebugging( + `Parsed JSON output from hook: ${jsonStringify(json)}`, + { level: 'verbose' }, + ) + } + + // Blocked if exit code 2 or JSON decision: 'block' + const jsonBlocked = + json && + !isAsyncHookJSONOutput(json) && + isSyncHookJSONOutput(json) && + json.decision === 'block' + const blocked = result.status === 2 || !!jsonBlocked + + // For successful hooks (exit code 0), use stdout; for failed hooks, use stderr + const output = + result.status === 0 ? result.stdout || '' : result.stderr || '' + + const watchPaths = + json && + isSyncHookJSONOutput(json) && + json.hookSpecificOutput && + 'watchPaths' in json.hookSpecificOutput + ? json.hookSpecificOutput.watchPaths + : undefined + + const systemMessage = + json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined + + return { + command: hook.command, + succeeded: result.status === 0, + output, + blocked, + watchPaths, + systemMessage, + } + } catch (error) { + // Clean up on error + cleanup?.() + + const errorMessage = + error instanceof Error ? error.message : String(error) + logForDebugging( + `${hookName} [${hook.command}] failed to run: ${errorMessage}`, + { level: 'error' }, + ) + return { + command: hook.command, + succeeded: false, + output: errorMessage, + blocked: false, + } + } + }, + ) + + // Wait for all hooks to complete and collect results + return await Promise.all(hookPromises) +} + +/** + * Execute pre-tool hooks if configured + * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') + * @param toolUseID The ID of the tool use + * @param toolInput The input that will be passed to the tool + * @param permissionMode Optional permission mode from toolPermissionContext + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @param toolUseContext Optional ToolUseContext for prompt-based hooks + * @returns Async generator that yields progress messages and returns blocking errors + */ +export async function* executePreToolHooks( + toolName: string, + toolUseID: string, + toolInput: ToolInput, + toolUseContext: ToolUseContext, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + requestPrompt?: ( + sourceName: string, + toolInputSummary?: string | null, + ) => (request: PromptRequest) => Promise, + toolInputSummary?: string | null, +): AsyncGenerator { + const appState = toolUseContext.getAppState() + const sessionId = toolUseContext.agentId ?? getSessionId() + if (!hasHookForEvent('PreToolUse', appState, sessionId)) { + return + } + + logForDebugging(`executePreToolHooks called for tool: ${toolName}`, { + level: 'verbose', + }) + + const hookInput: PreToolUseHookInput = { + ...createBaseHookInput(permissionMode, undefined, toolUseContext), + hook_event_name: 'PreToolUse', + tool_name: toolName, + tool_input: toolInput, + tool_use_id: toolUseID, + } + + yield* executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + requestPrompt, + toolInputSummary, + }) +} + +/** + * Execute post-tool hooks if configured + * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') + * @param toolUseID The ID of the tool use + * @param toolInput The input that was passed to the tool + * @param toolResponse The response from the tool + * @param toolUseContext ToolUseContext for prompt-based hooks + * @param permissionMode Optional permission mode from toolPermissionContext + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and blocking errors for automated feedback + */ +export async function* executePostToolHooks( + toolName: string, + toolUseID: string, + toolInput: ToolInput, + toolResponse: ToolResponse, + toolUseContext: ToolUseContext, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): AsyncGenerator { + const hookInput: PostToolUseHookInput = { + ...createBaseHookInput(permissionMode, undefined, toolUseContext), + hook_event_name: 'PostToolUse', + tool_name: toolName, + tool_input: toolInput, + tool_response: toolResponse, + tool_use_id: toolUseID, + } + + yield* executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + }) +} + +/** + * Execute post-tool-use-failure hooks if configured + * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') + * @param toolUseID The ID of the tool use + * @param toolInput The input that was passed to the tool + * @param error The error message from the failed tool call + * @param toolUseContext ToolUseContext for prompt-based hooks + * @param isInterrupt Whether the tool was interrupted by user + * @param permissionMode Optional permission mode from toolPermissionContext + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and blocking errors + */ +export async function* executePostToolUseFailureHooks( + toolName: string, + toolUseID: string, + toolInput: ToolInput, + error: string, + toolUseContext: ToolUseContext, + isInterrupt?: boolean, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): AsyncGenerator { + const appState = toolUseContext.getAppState() + const sessionId = toolUseContext.agentId ?? getSessionId() + if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { + return + } + + const hookInput: PostToolUseFailureHookInput = { + ...createBaseHookInput(permissionMode, undefined, toolUseContext), + hook_event_name: 'PostToolUseFailure', + tool_name: toolName, + tool_input: toolInput, + tool_use_id: toolUseID, + error, + is_interrupt: isInterrupt, + } + + yield* executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + }) +} + +export async function* executePermissionDeniedHooks( + toolName: string, + toolUseID: string, + toolInput: ToolInput, + reason: string, + toolUseContext: ToolUseContext, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): AsyncGenerator { + const appState = toolUseContext.getAppState() + const sessionId = toolUseContext.agentId ?? getSessionId() + if (!hasHookForEvent('PermissionDenied', appState, sessionId)) { + return + } + + const hookInput: PermissionDeniedHookInput = { + ...createBaseHookInput(permissionMode, undefined, toolUseContext), + hook_event_name: 'PermissionDenied', + tool_name: toolName, + tool_input: toolInput, + tool_use_id: toolUseID, + reason, + } + + yield* executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + }) +} + +/** + * Execute notification hooks if configured + * @param notificationData The notification data to pass to hooks + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Promise that resolves when all hooks complete + */ +export async function executeNotificationHooks( + notificationData: { + message: string + title?: string + notificationType: string + }, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise { + const { message, title, notificationType } = notificationData + const hookInput: NotificationHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'Notification', + message, + title, + notification_type: notificationType, + } + + await executeHooksOutsideREPL({ + hookInput, + timeoutMs, + matchQuery: notificationType, + }) +} + +export async function executeStopFailureHooks( + lastMessage: AssistantMessage, + toolUseContext?: ToolUseContext, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise { + const appState = toolUseContext?.getAppState() + // executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter + // hooks (registerFrontmatterHooks) key by agentId; gating with agentId here + // would pass the gate but fail execution. Align gate with execution. + const sessionId = getSessionId() + if (!hasHookForEvent('StopFailure', appState, sessionId)) return + + const lastAssistantText = + extractTextContent(lastMessage.message.content, '\n').trim() || undefined + + // Some createAssistantAPIErrorMessage call sites omit `error` (e.g. + // image-size at errors.ts:431). Default to 'unknown' so matcher filtering + // at getMatchingHooks:1525 always applies. + const error = lastMessage.error ?? 'unknown' + const hookInput: StopFailureHookInput = { + ...createBaseHookInput(undefined, undefined, toolUseContext), + hook_event_name: 'StopFailure', + error, + error_details: lastMessage.errorDetails, + last_assistant_message: lastAssistantText, + } + + await executeHooksOutsideREPL({ + getAppState: toolUseContext?.getAppState, + hookInput, + timeoutMs, + matchQuery: error, + }) +} + +/** + * Execute stop hooks if configured + * @param toolUseContext ToolUseContext for prompt-based hooks + * @param permissionMode permission mode from toolPermissionContext + * @param signal AbortSignal to cancel hook execution + * @param stopHookActive Whether this call is happening within another stop hook + * @param isSubagent Whether the current execution context is a subagent + * @param messages Optional conversation history for prompt/function hooks + * @returns Async generator that yields progress messages and blocking errors + */ +export async function* executeStopHooks( + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + stopHookActive: boolean = false, + subagentId?: AgentId, + toolUseContext?: ToolUseContext, + messages?: Message[], + agentType?: string, + requestPrompt?: ( + sourceName: string, + toolInputSummary?: string | null, + ) => (request: PromptRequest) => Promise, +): AsyncGenerator { + const hookEvent = subagentId ? 'SubagentStop' : 'Stop' + const appState = toolUseContext?.getAppState() + const sessionId = toolUseContext?.agentId ?? getSessionId() + if (!hasHookForEvent(hookEvent, appState, sessionId)) { + return + } + + // Extract text content from the last assistant message so hooks can + // inspect the final response without reading the transcript file. + const lastAssistantMessage = messages + ? getLastAssistantMessage(messages) + : undefined + const lastAssistantText = lastAssistantMessage + ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() || + undefined + : undefined + + const hookInput: StopHookInput | SubagentStopHookInput = subagentId + ? { + ...createBaseHookInput(permissionMode), + hook_event_name: 'SubagentStop', + stop_hook_active: stopHookActive, + agent_id: subagentId, + agent_transcript_path: getAgentTranscriptPath(subagentId), + agent_type: agentType ?? '', + last_assistant_message: lastAssistantText, + } + : { + ...createBaseHookInput(permissionMode), + hook_event_name: 'Stop', + stop_hook_active: stopHookActive, + last_assistant_message: lastAssistantText, + } + + // Trust check is now centralized in executeHooks() + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + signal, + timeoutMs, + toolUseContext, + messages, + requestPrompt, + }) +} + +/** + * Execute TeammateIdle hooks when a teammate is about to go idle. + * If a hook blocks (exit code 2), the teammate should continue working instead of going idle. + * @param teammateName The name of the teammate going idle + * @param teamName The team this teammate belongs to + * @param permissionMode Optional permission mode + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and blocking errors + */ +export async function* executeTeammateIdleHooks( + teammateName: string, + teamName: string, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): AsyncGenerator { + const hookInput: TeammateIdleHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'TeammateIdle', + teammate_name: teammateName, + team_name: teamName, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + signal, + timeoutMs, + }) +} + +/** + * Execute TaskCreated hooks when a task is being created. + * If a hook blocks (exit code 2), the task creation should be prevented and feedback returned. + * @param taskId The ID of the task being created + * @param taskSubject The subject/title of the task + * @param taskDescription Optional description of the task + * @param teammateName Optional name of the teammate creating the task + * @param teamName Optional team name + * @param permissionMode Optional permission mode + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId + * @returns Async generator that yields progress messages and blocking errors + */ +export async function* executeTaskCreatedHooks( + taskId: string, + taskSubject: string, + taskDescription?: string, + teammateName?: string, + teamName?: string, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + toolUseContext?: ToolUseContext, +): AsyncGenerator { + const hookInput: TaskCreatedHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'TaskCreated', + task_id: taskId, + task_subject: taskSubject, + task_description: taskDescription, + teammate_name: teammateName, + team_name: teamName, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + signal, + timeoutMs, + toolUseContext, + }) +} + +/** + * Execute TaskCompleted hooks when a task is being marked as completed. + * If a hook blocks (exit code 2), the task completion should be prevented and feedback returned. + * @param taskId The ID of the task being completed + * @param taskSubject The subject/title of the task + * @param taskDescription Optional description of the task + * @param teammateName Optional name of the teammate completing the task + * @param teamName Optional team name + * @param permissionMode Optional permission mode + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId + * @returns Async generator that yields progress messages and blocking errors + */ +export async function* executeTaskCompletedHooks( + taskId: string, + taskSubject: string, + taskDescription?: string, + teammateName?: string, + teamName?: string, + permissionMode?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + toolUseContext?: ToolUseContext, +): AsyncGenerator { + const hookInput: TaskCompletedHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'TaskCompleted', + task_id: taskId, + task_subject: taskSubject, + task_description: taskDescription, + teammate_name: teammateName, + team_name: teamName, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + signal, + timeoutMs, + toolUseContext, + }) +} + +/** + * Execute start hooks if configured + * @param prompt The user prompt that will be passed to the tool + * @param permissionMode Permission mode from toolPermissionContext + * @param toolUseContext ToolUseContext for prompt-based hooks + * @returns Async generator that yields progress messages and hook results + */ +export async function* executeUserPromptSubmitHooks( + prompt: string, + permissionMode: string, + toolUseContext: ToolUseContext, + requestPrompt?: ( + sourceName: string, + toolInputSummary?: string | null, + ) => (request: PromptRequest) => Promise, +): AsyncGenerator { + const appState = toolUseContext.getAppState() + const sessionId = toolUseContext.agentId ?? getSessionId() + if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) { + return + } + + const hookInput: UserPromptSubmitHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'UserPromptSubmit', + prompt, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + signal: toolUseContext.abortController.signal, + timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, + toolUseContext, + requestPrompt, + }) +} + +/** + * Execute session start hooks if configured + * @param source The source of the session start (startup, resume, clear) + * @param sessionId Optional The session id to use as hook input + * @param agentType Optional The agent type (from --agent flag) running this session + * @param model Optional The model being used for this session + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and hook results + */ +export async function* executeSessionStartHooks( + source: 'startup' | 'resume' | 'clear' | 'compact', + sessionId?: string, + agentType?: string, + model?: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + forceSyncExecution?: boolean, +): AsyncGenerator { + const hookInput: SessionStartHookInput = { + ...createBaseHookInput(undefined, sessionId), + hook_event_name: 'SessionStart', + source, + agent_type: agentType, + model, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + matchQuery: source, + signal, + timeoutMs, + forceSyncExecution, + }) +} + +/** + * Execute setup hooks if configured + * @param trigger The trigger type ('init' or 'maintenance') + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @param forceSyncExecution If true, async hooks will not be backgrounded + * @returns Async generator that yields progress messages and hook results + */ +export async function* executeSetupHooks( + trigger: 'init' | 'maintenance', + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + forceSyncExecution?: boolean, +): AsyncGenerator { + const hookInput: SetupHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'Setup', + trigger, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + matchQuery: trigger, + signal, + timeoutMs, + forceSyncExecution, + }) +} + +/** + * Execute subagent start hooks if configured + * @param agentId The unique identifier for the subagent + * @param agentType The type/name of the subagent being started + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and hook results + */ +export async function* executeSubagentStartHooks( + agentId: string, + agentType: string, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): AsyncGenerator { + const hookInput: SubagentStartHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'SubagentStart', + agent_id: agentId, + agent_type: agentType, + } + + yield* executeHooks({ + hookInput, + toolUseID: randomUUID(), + matchQuery: agentType, + signal, + timeoutMs, + }) +} + +/** + * Execute pre-compact hooks if configured + * @param compactData The compact data to pass to hooks + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Object with optional newCustomInstructions and userDisplayMessage + */ +export async function executePreCompactHooks( + compactData: { + trigger: 'manual' | 'auto' + customInstructions: string | null + }, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise<{ + newCustomInstructions?: string + userDisplayMessage?: string +}> { + const hookInput: PreCompactHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'PreCompact', + trigger: compactData.trigger, + custom_instructions: compactData.customInstructions, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + matchQuery: compactData.trigger, + signal, + timeoutMs, + }) + + if (results.length === 0) { + return {} + } + + // Extract custom instructions from successful hooks with non-empty output + const successfulOutputs = results + .filter(result => result.succeeded && result.output.trim().length > 0) + .map(result => result.output.trim()) + + // Build user display messages with command info + const displayMessages: string[] = [] + for (const result of results) { + if (result.succeeded) { + if (result.output.trim()) { + displayMessages.push( + `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`, + ) + } else { + displayMessages.push( + `PreCompact [${result.command}] completed successfully`, + ) + } + } else { + if (result.output.trim()) { + displayMessages.push( + `PreCompact [${result.command}] failed: ${result.output.trim()}`, + ) + } else { + displayMessages.push(`PreCompact [${result.command}] failed`) + } + } + } + + return { + newCustomInstructions: + successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined, + userDisplayMessage: + displayMessages.length > 0 ? displayMessages.join('\n') : undefined, + } +} + +/** + * Execute post-compact hooks if configured + * @param compactData The compact data to pass to hooks, including the summary + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Object with optional userDisplayMessage + */ +export async function executePostCompactHooks( + compactData: { + trigger: 'manual' | 'auto' + compactSummary: string + }, + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise<{ + userDisplayMessage?: string +}> { + const hookInput: PostCompactHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'PostCompact', + trigger: compactData.trigger, + compact_summary: compactData.compactSummary, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + matchQuery: compactData.trigger, + signal, + timeoutMs, + }) + + if (results.length === 0) { + return {} + } + + const displayMessages: string[] = [] + for (const result of results) { + if (result.succeeded) { + if (result.output.trim()) { + displayMessages.push( + `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`, + ) + } else { + displayMessages.push( + `PostCompact [${result.command}] completed successfully`, + ) + } + } else { + if (result.output.trim()) { + displayMessages.push( + `PostCompact [${result.command}] failed: ${result.output.trim()}`, + ) + } else { + displayMessages.push(`PostCompact [${result.command}] failed`) + } + } + } + + return { + userDisplayMessage: + displayMessages.length > 0 ? displayMessages.join('\n') : undefined, + } +} + +/** + * Execute session end hooks if configured + * @param reason The reason for ending the session + * @param options Optional parameters including app state functions and signal + * @returns Promise that resolves when all hooks complete + */ +export async function executeSessionEndHooks( + reason: ExitReason, + options?: { + getAppState?: () => AppState + setAppState?: (updater: (prev: AppState) => AppState) => void + signal?: AbortSignal + timeoutMs?: number + }, +): Promise { + const { + getAppState, + setAppState, + signal, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + } = options || {} + + const hookInput: SessionEndHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'SessionEnd', + reason, + } + + const results = await executeHooksOutsideREPL({ + getAppState, + hookInput, + matchQuery: reason, + signal, + timeoutMs, + }) + + // During shutdown, Ink is unmounted so we can write directly to stderr + for (const result of results) { + if (!result.succeeded && result.output) { + process.stderr.write( + `SessionEnd hook [${result.command}] failed: ${result.output}\n`, + ) + } + } + + // Clear session hooks after execution + if (setAppState) { + const sessionId = getSessionId() + clearSessionHooks(setAppState, sessionId) + } +} + +/** + * Execute permission request hooks if configured + * These hooks are called when a permission dialog would be displayed to the user. + * Hooks can approve or deny the permission request programmatically. + * @param toolName The name of the tool requesting permission + * @param toolUseID The ID of the tool use + * @param toolInput The input that would be passed to the tool + * @param toolUseContext ToolUseContext for the request + * @param permissionMode Optional permission mode from toolPermissionContext + * @param permissionSuggestions Optional permission suggestions (the "always allow" options) + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Async generator that yields progress messages and returns aggregated result + */ +export async function* executePermissionRequestHooks( + toolName: string, + toolUseID: string, + toolInput: ToolInput, + toolUseContext: ToolUseContext, + permissionMode?: string, + permissionSuggestions?: PermissionUpdate[], + signal?: AbortSignal, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + requestPrompt?: ( + sourceName: string, + toolInputSummary?: string | null, + ) => (request: PromptRequest) => Promise, + toolInputSummary?: string | null, +): AsyncGenerator { + logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`) + + const hookInput: PermissionRequestHookInput = { + ...createBaseHookInput(permissionMode, undefined, toolUseContext), + hook_event_name: 'PermissionRequest', + tool_name: toolName, + tool_input: toolInput, + permission_suggestions: permissionSuggestions, + } + + yield* executeHooks({ + hookInput, + toolUseID, + matchQuery: toolName, + signal, + timeoutMs, + toolUseContext, + requestPrompt, + toolInputSummary, + }) +} + +export type ConfigChangeSource = + | 'user_settings' + | 'project_settings' + | 'local_settings' + | 'policy_settings' + | 'skills' + +/** + * Execute config change hooks when configuration files change during a session. + * Fired by file watchers when settings, skills, or commands change on disk. + * Enables enterprise admins to audit/log configuration changes for security. + * + * Policy settings are enterprise-managed and must never be blockable by hooks. + * Hooks still fire (for audit logging) but blocking results are ignored 鈥?callers + * will always see an empty result for policy sources. + * + * @param source The type of config that changed + * @param filePath Optional path to the changed file + * @param timeoutMs Optional timeout in milliseconds for hook execution + */ +export async function executeConfigChangeHooks( + source: ConfigChangeSource, + filePath?: string, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise { + const hookInput: ConfigChangeHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'ConfigChange', + source, + file_path: filePath, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + timeoutMs, + matchQuery: source, + }) + + // Policy settings are enterprise-managed 鈥?hooks fire for audit logging + // but must never block policy changes from being applied + if (source === 'policy_settings') { + return results.map(r => ({ ...r, blocked: false })) + } + + return results +} + +async function executeEnvHooks( + hookInput: HookInput, + timeoutMs: number, +): Promise<{ + results: HookOutsideReplResult[] + watchPaths: string[] + systemMessages: string[] +}> { + const results = await executeHooksOutsideREPL({ hookInput, timeoutMs }) + if (results.length > 0) { + invalidateSessionEnvCache() + } + const watchPaths = results.flatMap(r => r.watchPaths ?? []) + const systemMessages = results + .map(r => r.systemMessage) + .filter((m): m is string => !!m) + return { results, watchPaths, systemMessages } +} + +export function executeCwdChangedHooks( + oldCwd: string, + newCwd: string, + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise<{ + results: HookOutsideReplResult[] + watchPaths: string[] + systemMessages: string[] +}> { + const hookInput: CwdChangedHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'CwdChanged', + old_cwd: oldCwd, + new_cwd: newCwd, + } + return executeEnvHooks(hookInput, timeoutMs) +} + +export function executeFileChangedHooks( + filePath: string, + event: 'change' | 'add' | 'unlink', + timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, +): Promise<{ + results: HookOutsideReplResult[] + watchPaths: string[] + systemMessages: string[] +}> { + const hookInput: FileChangedHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'FileChanged', + file_path: filePath, + event, + } + return executeEnvHooks(hookInput, timeoutMs) +} + +export type InstructionsLoadReason = + | 'session_start' + | 'nested_traversal' + | 'path_glob_match' + | 'include' + | 'compact' + +export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed' + +/** + * Check if InstructionsLoaded hooks are configured (without executing them). + * Callers should check this before invoking executeInstructionsLoadedHooks to avoid + * building hook inputs for every instruction file when no hook is configured. + * + * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered + * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session- + * derived hooks (structured output enforcement etc.) are internal and not checked. + */ +export function hasInstructionsLoadedHook(): boolean { + const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded'] + if (snapshotHooks && snapshotHooks.length > 0) return true + const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded'] + if (registeredHooks && registeredHooks.length > 0) return true + return false +} + +/** + * Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or + * .claude/rules/*.md) is loaded into context. Fire-and-forget 鈥?this hook is + * for observability/audit only and does not support blocking. + * + * Dispatch sites: + * - Eager load at session start (getMemoryFiles in claudemd.ts) + * - Eager reload after compaction (getMemoryFiles cache cleared by + * runPostCompactCleanup; next call reports load_reason: 'compact') + * - Lazy load when Claude touches a file that triggers nested CLAUDE.md or + * conditional rules with paths: frontmatter (memoryFilesToAttachments in + * attachments.ts) + */ +export async function executeInstructionsLoadedHooks( + filePath: string, + memoryType: InstructionsMemoryType, + loadReason: InstructionsLoadReason, + options?: { + globs?: string[] + triggerFilePath?: string + parentFilePath?: string + timeoutMs?: number + }, +): Promise { + const { + globs, + triggerFilePath, + parentFilePath, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + } = options ?? {} + + const hookInput: InstructionsLoadedHookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'InstructionsLoaded', + file_path: filePath, + memory_type: memoryType, + load_reason: loadReason, + globs, + trigger_file_path: triggerFilePath, + parent_file_path: parentFilePath, + } + + await executeHooksOutsideREPL({ + hookInput, + timeoutMs, + matchQuery: loadReason, + }) +} + +/** Result of an elicitation hook execution (non-REPL path). */ +export type ElicitationHookResult = { + elicitationResponse?: ElicitationResponse + blockingError?: HookBlockingError +} + +/** Result of an elicitation-result hook execution (non-REPL path). */ +export type ElicitationResultHookResult = { + elicitationResultResponse?: ElicitationResponse + blockingError?: HookBlockingError +} + +/** + * Parse elicitation-specific fields from a HookOutsideReplResult. + * Mirrors the relevant branches of processHookJSONOutput for Elicitation + * and ElicitationResult hook events. + */ +function parseElicitationHookOutput( + result: HookOutsideReplResult, + expectedEventName: 'Elicitation' | 'ElicitationResult', +): { + response?: ElicitationResponse + blockingError?: HookBlockingError +} { + // Exit code 2 = blocking (same as executeHooks path) + if (result.blocked && !result.succeeded) { + return { + blockingError: { + blockingError: result.output || `Elicitation blocked by hook`, + command: result.command, + }, + } + } + + if (!result.output.trim()) { + return {} + } + + // Try to parse JSON output for structured elicitation response + const trimmed = result.output.trim() + if (!trimmed.startsWith('{')) { + return {} + } + + try { + const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed)) + if (isAsyncHookJSONOutput(parsed)) { + return {} + } + if (!isSyncHookJSONOutput(parsed)) { + return {} + } + + // Check for top-level decision: 'block' (exit code 0 + JSON block) + if (parsed.decision === 'block' || result.blocked) { + return { + blockingError: { + blockingError: parsed.reason || 'Elicitation blocked by hook', + command: result.command, + }, + } + } + + const specific = parsed.hookSpecificOutput + if (!specific || specific.hookEventName !== expectedEventName) { + return {} + } + + if (!specific.action) { + return {} + } + + const response: ElicitationResponse = { + action: specific.action, + content: specific.content as ElicitationResponse['content'] | undefined, + } + + const out: { + response?: ElicitationResponse + blockingError?: HookBlockingError + } = { response } + + if (specific.action === 'decline') { + out.blockingError = { + blockingError: + parsed.reason || + (expectedEventName === 'Elicitation' + ? 'Elicitation denied by hook' + : 'Elicitation result blocked by hook'), + command: result.command, + } + } + + return out + } catch { + return {} + } +} + +export async function executeElicitationHooks({ + serverName, + message, + requestedSchema, + permissionMode, + signal, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + mode, + url, + elicitationId, +}: { + serverName: string + message: string + requestedSchema?: Record + permissionMode?: string + signal?: AbortSignal + timeoutMs?: number + mode?: 'form' | 'url' + url?: string + elicitationId?: string +}): Promise { + const hookInput: ElicitationHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'Elicitation', + mcp_server_name: serverName, + message, + mode, + url, + elicitation_id: elicitationId, + requested_schema: requestedSchema, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + matchQuery: serverName, + signal, + timeoutMs, + }) + + let elicitationResponse: ElicitationResponse | undefined + let blockingError: HookBlockingError | undefined + + for (const result of results) { + const parsed = parseElicitationHookOutput(result, 'Elicitation') + if (parsed.blockingError) { + blockingError = parsed.blockingError + } + if (parsed.response) { + elicitationResponse = parsed.response + } + } + + return { elicitationResponse, blockingError } +} + +export async function executeElicitationResultHooks({ + serverName, + action, + content, + permissionMode, + signal, + timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, + mode, + elicitationId, +}: { + serverName: string + action: 'accept' | 'decline' | 'cancel' + content?: Record + permissionMode?: string + signal?: AbortSignal + timeoutMs?: number + mode?: 'form' | 'url' + elicitationId?: string +}): Promise { + const hookInput: ElicitationResultHookInput = { + ...createBaseHookInput(permissionMode), + hook_event_name: 'ElicitationResult', + mcp_server_name: serverName, + elicitation_id: elicitationId, + mode, + action, + content, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + matchQuery: serverName, + signal, + timeoutMs, + }) + + let elicitationResultResponse: ElicitationResponse | undefined + let blockingError: HookBlockingError | undefined + + for (const result of results) { + const parsed = parseElicitationHookOutput(result, 'ElicitationResult') + if (parsed.blockingError) { + blockingError = parsed.blockingError + } + if (parsed.response) { + elicitationResultResponse = parsed.response + } + } + + return { elicitationResultResponse, blockingError } +} + +/** + * Execute status line command if configured + * @param statusLineInput The structured status input that will be converted to JSON + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns The status line text to display, or undefined if no command configured + */ +export async function executeStatusLineCommand( + statusLineInput: StatusLineCommandInput, + signal?: AbortSignal, + timeoutMs: number = 5000, // Short timeout for status line + logResult: boolean = false, +): Promise { + // Check if all hooks (including statusLine) are disabled by managed settings + if (shouldDisableAllHooksIncludingManaged()) { + return undefined + } + + // SECURITY: ALL hooks require workspace trust in interactive mode + // This centralized check prevents RCE vulnerabilities for all current and future hooks + if (shouldSkipHookDueToTrust()) { + logForDebugging( + `Skipping StatusLine command execution - workspace trust not accepted`, + ) + return undefined + } + + // When disableAllHooks is set in non-managed settings, only managed statusLine runs + // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) + let statusLine + if (shouldAllowManagedHooksOnly()) { + statusLine = getSettingsForSource('policySettings')?.statusLine + } else { + statusLine = getSettings_DEPRECATED()?.statusLine + } + + if (!statusLine || statusLine.type !== 'command') { + return undefined + } + + // Use provided signal or create a default one + const abortSignal = signal || AbortSignal.timeout(timeoutMs) + + try { + // Convert status input to JSON + const jsonInput = jsonStringify(statusLineInput) + + const result = await execCommandHook( + statusLine, + 'StatusLine', + 'statusLine', + jsonInput, + abortSignal, + randomUUID(), + ) + + if (result.aborted) { + return undefined + } + + // For successful hooks (exit code 0), use stdout + if (result.status === 0) { + // Trim and split output into lines, then join with newlines + const output = result.stdout + .trim() + .split('\n') + .flatMap(line => line.trim() || []) + .join('\n') + + if (output) { + if (logResult) { + logForDebugging( + `StatusLine [${statusLine.command}] completed with status ${result.status}`, + ) + } + return output + } + } else if (logResult) { + logForDebugging( + `StatusLine [${statusLine.command}] completed with status ${result.status}`, + { level: 'warn' }, + ) + } + + return undefined + } catch (error) { + logForDebugging(`Status hook failed: ${error}`, { level: 'error' }) + return undefined + } +} + +/** + * Execute file suggestion command if configured + * @param fileSuggestionInput The structured input that will be converted to JSON + * @param signal Optional AbortSignal to cancel hook execution + * @param timeoutMs Optional timeout in milliseconds for hook execution + * @returns Array of file paths, or empty array if no command configured + */ +export async function executeFileSuggestionCommand( + fileSuggestionInput: FileSuggestionCommandInput, + signal?: AbortSignal, + timeoutMs: number = 5000, // Short timeout for typeahead suggestions +): Promise { + // Check if all hooks are disabled by managed settings + if (shouldDisableAllHooksIncludingManaged()) { + return [] + } + + // SECURITY: ALL hooks require workspace trust in interactive mode + // This centralized check prevents RCE vulnerabilities for all current and future hooks + if (shouldSkipHookDueToTrust()) { + logForDebugging( + `Skipping FileSuggestion command execution - workspace trust not accepted`, + ) + return [] + } + + // When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs + // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) + let fileSuggestion + if (shouldAllowManagedHooksOnly()) { + fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion + } else { + fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion + } + + if (!fileSuggestion || fileSuggestion.type !== 'command') { + return [] + } + + // Use provided signal or create a default one + const abortSignal = signal || AbortSignal.timeout(timeoutMs) + + try { + const jsonInput = jsonStringify(fileSuggestionInput) + + const hook = { type: 'command' as const, command: fileSuggestion.command } + + const result = await execCommandHook( + hook, + 'FileSuggestion', + 'FileSuggestion', + jsonInput, + abortSignal, + randomUUID(), + ) + + if (result.aborted || result.status !== 0) { + return [] + } + + return result.stdout + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + } catch (error) { + logForDebugging(`File suggestion helper failed: ${error}`, { + level: 'error', + }) + return [] + } +} + +async function executeFunctionHook({ + hook, + messages, + hookName, + toolUseID, + hookEvent, + timeoutMs, + signal, +}: { + hook: FunctionHook + messages: Message[] + hookName: string + toolUseID: string + hookEvent: HookEvent + timeoutMs: number + signal?: AbortSignal +}): Promise { + const callbackTimeoutMs = hook.timeout ?? timeoutMs + const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { + timeoutMs: callbackTimeoutMs, + }) + + try { + // Check if already aborted + if (abortSignal.aborted) { + cleanup() + return { + outcome: 'cancelled', + hook, + } + } + + // Execute callback with abort signal + const passed = await new Promise((resolve, reject) => { + // Handle abort signal + const onAbort = () => reject(new Error('Function hook cancelled')) + abortSignal.addEventListener('abort', onAbort) + + // Execute callback + Promise.resolve(hook.callback(messages, abortSignal)) + .then(result => { + abortSignal.removeEventListener('abort', onAbort) + resolve(result) + }) + .catch(error => { + abortSignal.removeEventListener('abort', onAbort) + reject(error) + }) + }) + + cleanup() + + if (passed) { + return { + outcome: 'success', + hook, + } + } + return { + blockingError: { + blockingError: hook.errorMessage, + command: 'function', + }, + outcome: 'blocking', + hook, + } + } catch (error) { + cleanup() + + // Handle cancellation + if ( + error instanceof Error && + (error.message === 'Function hook cancelled' || + error.name === 'AbortError') + ) { + return { + outcome: 'cancelled', + hook, + } + } + + // Log for monitoring + logError(error) + return { + message: createAttachmentMessage({ + type: 'hook_error_during_execution', + hookName, + toolUseID, + hookEvent, + content: + error instanceof Error + ? error.message + : 'Function hook execution error', + }), + outcome: 'non_blocking_error', + hook, + } + } +} + +async function executeHookCallback({ + toolUseID, + hook, + hookEvent, + hookInput, + signal, + hookIndex, + toolUseContext, +}: { + toolUseID: string + hook: HookCallback + hookEvent: HookEvent + hookInput: HookInput + signal: AbortSignal + hookIndex?: number + toolUseContext?: ToolUseContext +}): Promise { + // Create context for callbacks that need state access + const context = toolUseContext + ? { + getAppState: toolUseContext.getAppState, + updateAttributionState: toolUseContext.updateAttributionState, + } + : undefined + const json = await hook.callback( + hookInput, + toolUseID, + signal, + hookIndex, + context, + ) + if (isAsyncHookJSONOutput(json)) { + return { + outcome: 'success', + hook, + } + } + + const processed = processHookJSONOutput({ + json, + command: 'callback', + // TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging + hookName: `${hookEvent}:Callback`, + toolUseID, + hookEvent, + expectedHookEvent: hookEvent, + // Callbacks don't have stdout/stderr/exitCode + stdout: undefined, + stderr: undefined, + exitCode: undefined, + }) + return { + ...processed, + outcome: 'success', + hook, + } +} + +/** + * Check if WorktreeCreate hooks are configured (without executing them). + * + * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered + * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). + * + * Must mirror the managedOnly filtering in getHooksConfig() 鈥?when + * shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are + * skipped at execution, so we must also skip them here. Otherwise this returns + * true but executeWorktreeCreateHook() finds no matching hooks and throws, + * blocking the git-worktree fallback. + */ +export function hasWorktreeCreateHook(): boolean { + const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate'] + if (snapshotHooks && snapshotHooks.length > 0) return true + const registeredHooks = getRegisteredHooks()?.['WorktreeCreate'] + if (!registeredHooks || registeredHooks.length === 0) return false + // Mirror getHooksConfig(): skip plugin hooks in managed-only mode + const managedOnly = shouldAllowManagedHooksOnly() + return registeredHooks.some( + matcher => !(managedOnly && 'pluginRoot' in matcher), + ) +} + +/** + * Execute WorktreeCreate hooks. + * Returns the worktree path from hook stdout. + * Throws if hooks fail or produce no output. + * Callers should check hasWorktreeCreateHook() before calling this. + */ +export async function executeWorktreeCreateHook( + name: string, +): Promise<{ worktreePath: string }> { + const hookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'WorktreeCreate' as const, + name, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, + }) + + // Find the first successful result with non-empty output + const successfulResult = results.find( + r => r.succeeded && r.output.trim().length > 0, + ) + + if (!successfulResult) { + const failedOutputs = results + .filter(r => !r.succeeded) + .map(r => `${r.command}: ${r.output.trim() || 'no output'}`) + throw new Error( + `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`, + ) + } + + const worktreePath = successfulResult.output.trim() + return { worktreePath } +} + +/** + * Execute WorktreeRemove hooks if configured. + * Returns true if hooks were configured and ran, false if no hooks are configured. + * + * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered + * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). + */ +export async function executeWorktreeRemoveHook( + worktreePath: string, +): Promise { + const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove'] + const registeredHooks = getRegisteredHooks()?.['WorktreeRemove'] + const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0 + const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0 + if (!hasSnapshotHooks && !hasRegisteredHooks) { + return false + } + + const hookInput = { + ...createBaseHookInput(undefined), + hook_event_name: 'WorktreeRemove' as const, + worktree_path: worktreePath, + } + + const results = await executeHooksOutsideREPL({ + hookInput, + timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, + }) + + if (results.length === 0) { + return false + } + + for (const result of results) { + if (!result.succeeded) { + logForDebugging( + `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`, + { level: 'error' }, + ) + } + } + + return true +} + +function getHookDefinitionsForTelemetry( + matchedHooks: MatchedHook[], +): Array<{ type: string; command?: string; prompt?: string; name?: string }> { + return matchedHooks.map(({ hook }) => { + if (hook.type === 'command') { + return { type: 'command', command: hook.command } + } else if (hook.type === 'prompt') { + return { type: 'prompt', prompt: hook.prompt } + } else if (hook.type === 'http') { + return { type: 'http', command: hook.url } + } else if (hook.type === 'function') { + return { type: 'function', name: 'function' } + } else if (hook.type === 'callback') { + return { type: 'callback', name: 'callback' } + } + return { type: 'unknown' } + }) +} diff --git a/hook-dump/src_utils_hooks_AsyncHookRegistry.ts b/hook-dump/src_utils_hooks_AsyncHookRegistry.ts new file mode 100644 index 0000000000..6d063d0e45 --- /dev/null +++ b/hook-dump/src_utils_hooks_AsyncHookRegistry.ts @@ -0,0 +1,312 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\AsyncHookRegistry.ts +LINES: 309 +========== +import type { + AsyncHookJSONOutput, + HookEvent, + SyncHookJSONOutput, +} from 'src/entrypoints/agentSdkTypes.js' +import { logForDebugging } from '../debug.js' +import type { ShellCommand } from '../ShellCommand.js' +import { invalidateSessionEnvCache } from '../sessionEnvironment.js' +import { jsonParse, jsonStringify } from '../slowOperations.js' +import { emitHookResponse, startHookProgressInterval } from './hookEvents.js' + +export type PendingAsyncHook = { + processId: string + hookId: string + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + startTime: number + timeout: number + command: string + responseAttachmentSent: boolean + shellCommand?: ShellCommand + stopProgressInterval: () => void +} + +// Global registry state +const pendingHooks = new Map() + +export function registerPendingAsyncHook({ + processId, + hookId, + asyncResponse, + hookName, + hookEvent, + command, + shellCommand, + toolName, + pluginId, +}: { + processId: string + hookId: string + asyncResponse: AsyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + command: string + shellCommand: ShellCommand + toolName?: string + pluginId?: string +}): void { + const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s + logForDebugging( + `Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`, + ) + const stopProgressInterval = startHookProgressInterval({ + hookId, + hookName, + hookEvent, + getOutput: async () => { + const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput + if (!taskOutput) { + return { stdout: '', stderr: '', output: '' } + } + const stdout = await taskOutput.getStdout() + const stderr = taskOutput.getStderr() + return { stdout, stderr, output: stdout + stderr } + }, + }) + pendingHooks.set(processId, { + processId, + hookId, + hookName, + hookEvent, + toolName, + pluginId, + command, + startTime: Date.now(), + timeout, + responseAttachmentSent: false, + shellCommand, + stopProgressInterval, + }) +} + +export function getPendingAsyncHooks(): PendingAsyncHook[] { + return Array.from(pendingHooks.values()).filter( + hook => !hook.responseAttachmentSent, + ) +} + +async function finalizeHook( + hook: PendingAsyncHook, + exitCode: number, + outcome: 'success' | 'error' | 'cancelled', +): Promise { + hook.stopProgressInterval() + const taskOutput = hook.shellCommand?.taskOutput + const stdout = taskOutput ? await taskOutput.getStdout() : '' + const stderr = taskOutput?.getStderr() ?? '' + hook.shellCommand?.cleanup() + emitHookResponse({ + hookId: hook.hookId, + hookName: hook.hookName, + hookEvent: hook.hookEvent, + output: stdout + stderr, + stdout, + stderr, + exitCode, + outcome, + }) +} + +export async function checkForAsyncHookResponses(): Promise< + Array<{ + processId: string + response: SyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + stdout: string + stderr: string + exitCode?: number + }> +> { + const responses: { + processId: string + response: SyncHookJSONOutput + hookName: string + hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' + toolName?: string + pluginId?: string + stdout: string + stderr: string + exitCode?: number + }[] = [] + + const pendingCount = pendingHooks.size + logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`) + + // Snapshot hooks before processing 鈥?we'll mutate the map after. + const hooks = Array.from(pendingHooks.values()) + + const settled = await Promise.allSettled( + hooks.map(async hook => { + const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? '' + const stderr = hook.shellCommand?.taskOutput.getStderr() ?? '' + logForDebugging( + `Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`, + ) + + if (!hook.shellCommand) { + logForDebugging( + `Hooks: Hook ${hook.processId} has no shell command, removing from registry`, + ) + hook.stopProgressInterval() + return { type: 'remove' as const, processId: hook.processId } + } + + logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`) + + if (hook.shellCommand.status === 'killed') { + logForDebugging( + `Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`, + ) + hook.stopProgressInterval() + hook.shellCommand.cleanup() + return { type: 'remove' as const, processId: hook.processId } + } + + if (hook.shellCommand.status !== 'completed') { + return { type: 'skip' as const } + } + + if (hook.responseAttachmentSent || !stdout.trim()) { + logForDebugging( + `Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`, + ) + hook.stopProgressInterval() + return { type: 'remove' as const, processId: hook.processId } + } + + const lines = stdout.split('\n') + logForDebugging( + `Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`, + ) + + const execResult = await hook.shellCommand.result + const exitCode = execResult.code + + let response: SyncHookJSONOutput = {} + for (const line of lines) { + if (line.trim().startsWith('{')) { + logForDebugging( + `Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`, + ) + try { + const parsed = jsonParse(line.trim()) + if (!('async' in parsed)) { + logForDebugging( + `Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`, + ) + response = parsed + break + } + } catch { + logForDebugging( + `Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`, + ) + } + } + } + + hook.responseAttachmentSent = true + await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error') + + return { + type: 'response' as const, + processId: hook.processId, + isSessionStart: hook.hookEvent === 'SessionStart', + payload: { + processId: hook.processId, + response, + hookName: hook.hookName, + hookEvent: hook.hookEvent, + toolName: hook.toolName, + pluginId: hook.pluginId, + stdout, + stderr, + exitCode, + }, + } + }), + ) + + // allSettled 鈥?isolate failures so one throwing callback doesn't orphan + // already-applied side effects (responseAttachmentSent, finalizeHook) from others. + let sessionStartCompleted = false + for (const s of settled) { + if (s.status !== 'fulfilled') { + logForDebugging( + `Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`, + { level: 'error' }, + ) + continue + } + const r = s.value + if (r.type === 'remove') { + pendingHooks.delete(r.processId) + } else if (r.type === 'response') { + responses.push(r.payload) + pendingHooks.delete(r.processId) + if (r.isSessionStart) sessionStartCompleted = true + } + } + + if (sessionStartCompleted) { + logForDebugging( + `Invalidating session env cache after SessionStart hook completed`, + ) + invalidateSessionEnvCache() + } + + logForDebugging( + `Hooks: checkForNewResponses returning ${responses.length} responses`, + ) + return responses +} + +export function removeDeliveredAsyncHooks(processIds: string[]): void { + for (const processId of processIds) { + const hook = pendingHooks.get(processId) + if (hook && hook.responseAttachmentSent) { + logForDebugging(`Hooks: Removing delivered hook ${processId}`) + hook.stopProgressInterval() + pendingHooks.delete(processId) + } + } +} + +export async function finalizePendingAsyncHooks(): Promise { + const hooks = Array.from(pendingHooks.values()) + await Promise.all( + hooks.map(async hook => { + if (hook.shellCommand?.status === 'completed') { + const result = await hook.shellCommand.result + await finalizeHook( + hook, + result.code, + result.code === 0 ? 'success' : 'error', + ) + } else { + if (hook.shellCommand && hook.shellCommand.status !== 'killed') { + hook.shellCommand.kill() + } + await finalizeHook(hook, 1, 'cancelled') + } + }), + ) + pendingHooks.clear() +} + +// Test utility function to clear all hooks +export function clearAllAsyncHooks(): void { + for (const hook of pendingHooks.values()) { + hook.stopProgressInterval() + } + pendingHooks.clear() +} diff --git a/hook-dump/src_utils_hooks_apiQueryHookHelper.ts b/hook-dump/src_utils_hooks_apiQueryHookHelper.ts new file mode 100644 index 0000000000..8269cf3c5b --- /dev/null +++ b/hook-dump/src_utils_hooks_apiQueryHookHelper.ts @@ -0,0 +1,144 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\apiQueryHookHelper.ts +LINES: 141 +========== +import { randomUUID } from 'crypto' +import type { QuerySource } from '../../constants/querySource.js' +import { queryModelWithoutStreaming } from '../../services/api/claude.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../../utils/abortController.js' +import { logError } from '../../utils/log.js' +import { toError } from '../errors.js' +import { extractTextContent } from '../messages.js' +import { asSystemPrompt } from '../systemPromptType.js' +import type { REPLHookContext } from './postSamplingHooks.js' + +export type ApiQueryHookContext = REPLHookContext & { + queryMessageCount?: number +} + +export type ApiQueryHookConfig = { + name: QuerySource + shouldRun: (context: ApiQueryHookContext) => Promise + + // Build the complete message list to send to the API + buildMessages: (context: ApiQueryHookContext) => Message[] + + // Optional: override system prompt (defaults to context.systemPrompt) + systemPrompt?: string + + // Optional: whether to use tools from context (defaults to true) + // Set to false to pass empty tools array + useTools?: boolean + + parseResponse: (content: string, context: ApiQueryHookContext) => TResult + logResult: ( + result: ApiQueryResult, + context: ApiQueryHookContext, + ) => void + // Must be a function to ensure lazy loading (config is accessed before allowed) + // Receives context so callers can inherit the main loop model if desired. + getModel: (context: ApiQueryHookContext) => string +} + +export type ApiQueryResult = + | { + type: 'success' + queryName: string + result: TResult + messageId: string + model: string + uuid: string + } + | { + type: 'error' + queryName: string + error: Error + uuid: string + } + +export function createApiQueryHook( + config: ApiQueryHookConfig, +) { + return async (context: ApiQueryHookContext): Promise => { + try { + const shouldRun = await config.shouldRun(context) + if (!shouldRun) { + return + } + + const uuid = randomUUID() + + // Build messages using the config's buildMessages function + const messages = config.buildMessages(context) + context.queryMessageCount = messages.length + + // Use config's system prompt if provided, otherwise use context's + const systemPrompt = config.systemPrompt + ? asSystemPrompt([config.systemPrompt]) + : context.systemPrompt + + // Use config's tools preference (defaults to true = use context tools) + const useTools = config.useTools ?? true + const tools = useTools ? context.toolUseContext.options.tools : [] + + // Get model (lazy loaded) + const model = config.getModel(context) + + // Make API call + const response = await queryModelWithoutStreaming({ + messages, + systemPrompt, + thinkingConfig: { type: 'disabled' as const }, + tools, + signal: createAbortController().signal, + options: { + getToolPermissionContext: async () => { + const appState = context.toolUseContext.getAppState() + return appState.toolPermissionContext + }, + model, + toolChoice: undefined, + isNonInteractiveSession: + context.toolUseContext.options.isNonInteractiveSession, + hasAppendSystemPrompt: + !!context.toolUseContext.options.appendSystemPrompt, + temperatureOverride: 0, + agents: context.toolUseContext.options.agentDefinitions.activeAgents, + querySource: config.name, + mcpTools: [], + agentId: context.toolUseContext.agentId, + }, + }) + + // Parse response + const content = extractTextContent(response.message.content).trim() + + try { + const result = config.parseResponse(content, context) + config.logResult( + { + type: 'success', + queryName: config.name, + result, + messageId: response.message.id, + model, + uuid, + }, + context, + ) + } catch (error) { + config.logResult( + { + type: 'error', + queryName: config.name, + error: error as Error, + uuid, + }, + context, + ) + } + } catch (error) { + logError(toError(error)) + } + } +} diff --git a/hook-dump/src_utils_hooks_execAgentHook.ts b/hook-dump/src_utils_hooks_execAgentHook.ts new file mode 100644 index 0000000000..a4614e0a8c --- /dev/null +++ b/hook-dump/src_utils_hooks_execAgentHook.ts @@ -0,0 +1,342 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execAgentHook.ts +LINES: 339 +========== +import { randomUUID } from 'crypto' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { query } from '../../query.js' +import { logEvent } from '../../services/analytics/index.js' +import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' +import type { ToolUseContext } from '../../Tool.js' +import { type Tool, toolMatchesName } from '../../Tool.js' +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' +import { asAgentId } from '../../types/ids.js' +import type { Message } from '../../types/message.js' +import { createAbortController } from '../abortController.js' +import { createAttachmentMessage } from '../attachments.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import type { HookResult } from '../hooks.js' +import { createUserMessage, handleMessageFromStream } from '../messages.js' +import { getSmallFastModel } from '../model/model.js' +import { hasPermissionsToUseTool } from '../permissions/permissions.js' +import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' +import type { AgentHook } from '../settings/types.js' +import { jsonStringify } from '../slowOperations.js' +import { asSystemPrompt } from '../systemPromptType.js' +import { + addArgumentsToPrompt, + createStructuredOutputTool, + hookResponseSchema, + registerStructuredOutputEnforcement, +} from './hookHelpers.js' +import { clearSessionHooks } from './sessionHooks.js' + +/** + * Execute an agent-based hook using a multi-turn LLM query + */ +export async function execAgentHook( + hook: AgentHook, + hookName: string, + hookEvent: HookEvent, + jsonInput: string, + signal: AbortSignal, + toolUseContext: ToolUseContext, + toolUseID: string | undefined, + // Kept for signature stability with the other exec*Hook functions. + // Was used by hook.prompt(messages) before the .transform() was removed + // (CC-79) 鈥?the only consumer of that was ExitPlanModeV2Tool's + // programmatic construction, since refactored into VerifyPlanExecutionTool. + _messages: Message[], + agentName?: string, +): Promise { + const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` + + // Get transcript path from context + const transcriptPath = toolUseContext.agentId + ? getAgentTranscriptPath(toolUseContext.agentId) + : getTranscriptPath() + const hookStartTime = Date.now() + try { + // Replace $ARGUMENTS with the JSON input + const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) + logForDebugging( + `Hooks: Processing agent hook with prompt: ${processedPrompt}`, + ) + + // Create user message directly - no need for processUserInput which would + // trigger UserPromptSubmit hooks and cause infinite recursion + const userMessage = createUserMessage({ content: processedPrompt }) + const agentMessages = [userMessage] + + logForDebugging( + `Hooks: Starting agent query with ${agentMessages.length} messages`, + ) + + // Setup timeout and combine with parent signal + const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 + const hookAbortController = createAbortController() + + // Combine parent signal with timeout, and have it abort our controller + const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = + createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) + const onParentTimeout = () => hookAbortController.abort() + parentTimeoutSignal.addEventListener('abort', onParentTimeout) + + // Combined signal is just our controller's signal now + const combinedSignal = hookAbortController.signal + + try { + // Create StructuredOutput tool with our schema + const structuredOutputTool = createStructuredOutputTool() + + // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas + // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) + const filteredTools = toolUseContext.options.tools.filter( + tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), + ) + + // Use all available tools plus our structured output tool + // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents + // or entering plan mode, and filter out duplicate StructuredOutput tools + const tools: Tool[] = [ + ...filteredTools.filter( + tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), + ), + structuredOutputTool, + ] + + const systemPrompt = asSystemPrompt([ + `You are verifying a stop condition in Claude Code. Your task is to verify that the agent completed the given plan. The conversation transcript is available at: ${transcriptPath}\nYou can read this file to analyze the conversation history if needed. + +Use the available tools to inspect the codebase and verify the condition. +Use as few steps as possible - be efficient and direct. + +When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: +- ok: true if the condition is met +- ok: false with reason if the condition is not met`, + ]) + + const model = hook.model ?? getSmallFastModel() + const MAX_AGENT_TURNS = 50 + + // Create unique agentId for this hook agent + const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) + + // Create a modified toolUseContext for the agent + const agentToolUseContext: ToolUseContext = { + ...toolUseContext, + agentId: hookAgentId, + abortController: hookAbortController, + options: { + ...toolUseContext.options, + tools, + mainLoopModel: model, + isNonInteractiveSession: true, + thinkingConfig: { type: 'disabled' as const }, + }, + setInProgressToolUseIDs: () => {}, + getAppState() { + const appState = toolUseContext.getAppState() + // Add session rule to allow reading transcript file + const existingSessionRules = + appState.toolPermissionContext.alwaysAllowRules.session ?? [] + return { + ...appState, + toolPermissionContext: { + ...appState.toolPermissionContext, + mode: 'dontAsk' as const, + alwaysAllowRules: { + ...appState.toolPermissionContext.alwaysAllowRules, + session: [...existingSessionRules, `Read(/${transcriptPath})`], + }, + }, + } + }, + } + + // Register a session-level stop hook to enforce structured output + registerStructuredOutputEnforcement( + toolUseContext.setAppState, + hookAgentId, + ) + + let structuredOutputResult: { ok: boolean; reason?: string } | null = null + let turnCount = 0 + let hitMaxTurns = false + + // Use query() for multi-turn execution + for await (const message of query({ + messages: agentMessages, + systemPrompt, + userContext: {}, + systemContext: {}, + canUseTool: hasPermissionsToUseTool, + toolUseContext: agentToolUseContext, + querySource: 'hook_agent', + })) { + // Process stream events to update response length in the spinner + handleMessageFromStream( + message, + () => {}, // onMessage - we handle messages below + newContent => + toolUseContext.setResponseLength( + length => length + newContent.length, + ), + toolUseContext.setStreamMode ?? (() => {}), + () => {}, // onStreamingToolUses - not needed for hooks + ) + + // Skip streaming events for further processing + if ( + message.type === 'stream_event' || + message.type === 'stream_request_start' + ) { + continue + } + + // Count assistant turns + if (message.type === 'assistant') { + turnCount++ + + // Check if we've hit the turn limit + if (turnCount >= MAX_AGENT_TURNS) { + hitMaxTurns = true + logForDebugging( + `Hooks: Agent turn ${turnCount} hit max turns, aborting`, + ) + hookAbortController.abort() + break + } + } + + // Check for structured output in attachments + if ( + message.type === 'attachment' && + message.attachment.type === 'structured_output' + ) { + const parsed = hookResponseSchema().safeParse(message.attachment.data) + if (parsed.success) { + structuredOutputResult = parsed.data + logForDebugging( + `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, + ) + // Got structured output, abort and exit + hookAbortController.abort() + break + } + } + } + + parentTimeoutSignal.removeEventListener('abort', onParentTimeout) + cleanupCombinedSignal() + + // Clean up the session hook we registered for this agent + clearSessionHooks(toolUseContext.setAppState, hookAgentId) + + // Check if we got a result + if (!structuredOutputResult) { + // If we hit max turns, just log and return cancelled (no UI message) + if (hitMaxTurns) { + logForDebugging( + `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, + ) + logEvent('tengu_agent_stop_hook_max_turns', { + durationMs: Date.now() - hookStartTime, + turnCount, + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'cancelled', + } + } + + // For other cases (e.g., agent finished without calling structured output tool), + // just log and return cancelled (don't show error to user) + logForDebugging(`Hooks: Agent hook did not return structured output`) + logEvent('tengu_agent_stop_hook_error', { + durationMs: Date.now() - hookStartTime, + turnCount, + errorType: 1, // 1 = no structured output + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'cancelled', + } + } + + // Return result based on structured output + if (!structuredOutputResult.ok) { + logForDebugging( + `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, + ) + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, + command: hook.prompt, + }, + } + } + + // Condition was met + logForDebugging(`Hooks: Agent hook condition was met`) + logEvent('tengu_agent_stop_hook_success', { + durationMs: Date.now() - hookStartTime, + turnCount, + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'success', + message: createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + content: '', + }), + } + } catch (error) { + parentTimeoutSignal.removeEventListener('abort', onParentTimeout) + cleanupCombinedSignal() + + if (combinedSignal.aborted) { + return { + hook, + outcome: 'cancelled', + } + } + throw error + } + } catch (error) { + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) + logEvent('tengu_agent_stop_hook_error', { + durationMs: Date.now() - hookStartTime, + errorType: 2, // 2 = general error + agentName: + agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Error executing agent hook: ${errorMsg}`, + stdout: '', + exitCode: 1, + }), + } + } +} diff --git a/hook-dump/src_utils_hooks_execHttpHook.ts b/hook-dump/src_utils_hooks_execHttpHook.ts new file mode 100644 index 0000000000..5975133fe7 --- /dev/null +++ b/hook-dump/src_utils_hooks_execHttpHook.ts @@ -0,0 +1,245 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execHttpHook.ts +LINES: 242 +========== +import axios from 'axios' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import { getProxyUrl, shouldBypassProxy } from '../proxy.js' +// Import as namespace so spyOn works in tests (direct imports bypass spies) +import * as settingsModule from '../settings/settings.js' +import type { HttpHook } from '../settings/types.js' +import { ssrfGuardedLookup } from './ssrfGuard.js' + +const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS) + +/** + * Get the sandbox proxy config for routing HTTP hook requests through the + * sandbox network proxy when sandboxing is enabled. + * + * Uses dynamic import to avoid a static import cycle + * (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook). + */ +async function getSandboxProxyConfig(): Promise< + { host: string; port: number; protocol: string } | undefined +> { + const { SandboxManager } = await import('../sandbox/sandbox-adapter.js') + + if (!SandboxManager.isSandboxingEnabled()) { + return undefined + } + + // Wait for the sandbox network proxy to finish initializing. In REPL mode, + // SandboxManager.initialize() is fire-and-forget so the proxy may not be + // ready yet when the first hook fires. + await SandboxManager.waitForNetworkInitialization() + + const proxyPort = SandboxManager.getProxyPort() + if (!proxyPort) { + return undefined + } + + return { host: '127.0.0.1', port: proxyPort, protocol: 'http' } +} + +/** + * Read HTTP hook allowlist restrictions from merged settings (all sources). + * Follows the allowedMcpServers precedent: arrays concatenate across sources. + * When allowManagedHooksOnly is set in managed settings, only admin-defined + * hooks run anyway, so no separate lock-down boolean is needed here. + */ +function getHttpHookPolicy(): { + allowedUrls: string[] | undefined + allowedEnvVars: string[] | undefined +} { + const settings = settingsModule.getInitialSettings() + return { + allowedUrls: settings.allowedHttpHookUrls, + allowedEnvVars: settings.httpHookAllowedEnvVars, + } +} + +/** + * Match a URL against a pattern with * as a wildcard (any characters). + * Same semantics as the MCP server allowlist patterns. + */ +function urlMatchesPattern(url: string, pattern: string): boolean { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') + const regexStr = escaped.replace(/\*/g, '.*') + return new RegExp(`^${regexStr}$`).test(url) +} + +/** + * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header + * injection (CRLF injection) via env var values or hook-configured header + * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise + * inject a second header into the request. + */ +function sanitizeHeaderValue(value: string): string { + // eslint-disable-next-line no-control-regex + return value.replace(/[\r\n\x00]/g, '') +} + +/** + * Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env, + * but only for variable names present in the allowlist. References to variables + * not in the allowlist are replaced with empty strings to prevent exfiltration + * of secrets via project-configured HTTP hooks. + * + * The result is sanitized to strip CR/LF/NUL bytes to prevent header injection. + */ +function interpolateEnvVars( + value: string, + allowedEnvVars: ReadonlySet, +): string { + const interpolated = value.replace( + /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, + (_, braced, unbraced) => { + const varName = braced ?? unbraced + if (!allowedEnvVars.has(varName)) { + logForDebugging( + `Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`, + { level: 'warn' }, + ) + return '' + } + return process.env[varName] ?? '' + }, + ) + return sanitizeHeaderValue(interpolated) +} + +/** + * Execute an HTTP hook by POSTing the hook input JSON to the configured URL. + * Returns the raw response for the caller to interpret. + * + * When sandboxing is enabled, requests are routed through the sandbox network + * proxy which enforces the domain allowlist. The proxy returns HTTP 403 for + * blocked domains. + * + * Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that + * secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json. + * Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved; + * all other references are replaced with empty strings. + */ +export async function execHttpHook( + hook: HttpHook, + _hookEvent: HookEvent, + jsonInput: string, + signal?: AbortSignal, +): Promise<{ + ok: boolean + statusCode?: number + body: string + error?: string + aborted?: boolean +}> { + // Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics: + // undefined 鈫?no restriction; [] 鈫?block all; non-empty 鈫?must match a pattern. + const policy = getHttpHookPolicy() + if (policy.allowedUrls !== undefined) { + const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p)) + if (!matched) { + const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls` + logForDebugging(msg, { level: 'warn' }) + return { ok: false, body: '', error: msg } + } + } + + const timeoutMs = hook.timeout + ? hook.timeout * 1000 + : DEFAULT_HTTP_HOOK_TIMEOUT_MS + + const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( + signal, + { timeoutMs }, + ) + + try { + // Build headers with env var interpolation in values + const headers: Record = { + 'Content-Type': 'application/json', + } + if (hook.headers) { + // Intersect hook's allowedEnvVars with policy allowlist when policy is set + const hookVars = hook.allowedEnvVars ?? [] + const effectiveVars = + policy.allowedEnvVars !== undefined + ? hookVars.filter(v => policy.allowedEnvVars!.includes(v)) + : hookVars + const allowedEnvVars = new Set(effectiveVars) + for (const [name, value] of Object.entries(hook.headers)) { + headers[name] = interpolateEnvVars(value, allowedEnvVars) + } + } + + // Route through sandbox network proxy when available. The proxy enforces + // the domain allowlist and returns 403 for blocked domains. + const sandboxProxy = await getSandboxProxyConfig() + + // Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY). + // When set, configureGlobalAgents() has already installed a request + // interceptor that sets httpsAgent to an HttpsProxyAgent 鈥?the proxy + // handles DNS for the target. Skip the SSRF guard in that case, same + // as we do for the sandbox proxy, so that we don't accidentally block + // a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128). + const envProxyActive = + !sandboxProxy && + getProxyUrl() !== undefined && + !shouldBypassProxy(hook.url) + + if (sandboxProxy) { + logForDebugging( + `Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`, + ) + } else if (envProxyActive) { + logForDebugging( + `Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`, + ) + } else { + logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`) + } + + const response = await axios.post(hook.url, jsonInput, { + headers, + signal: combinedSignal, + responseType: 'text', + validateStatus: () => true, + maxRedirects: 0, + // Explicit false prevents axios's own env-var proxy detection; when an + // env-var proxy is configured, the global axios interceptor installed + // by configureGlobalAgents() handles it via httpsAgent instead. + proxy: sandboxProxy ?? false, + // SSRF guard: validate resolved IPs, block private/link-local ranges + // (but allow loopback for local dev). Skipped when any proxy is in + // use 鈥?the proxy performs DNS for the target, and applying the + // guard would instead validate the proxy's own IP, breaking + // connections to corporate proxies on private networks. + lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup, + }) + + cleanup() + + const body = response.data ?? '' + logForDebugging( + `Hooks: HTTP hook response status ${response.status}, body length ${body.length}`, + ) + + return { + ok: response.status >= 200 && response.status < 300, + statusCode: response.status, + body, + } + } catch (error) { + cleanup() + + if (combinedSignal.aborted) { + return { ok: false, body: '', aborted: true } + } + + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' }) + return { ok: false, body: '', error: errorMsg } + } +} diff --git a/hook-dump/src_utils_hooks_execPromptHook.ts b/hook-dump/src_utils_hooks_execPromptHook.ts new file mode 100644 index 0000000000..cfb147e5c3 --- /dev/null +++ b/hook-dump/src_utils_hooks_execPromptHook.ts @@ -0,0 +1,257 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execPromptHook.ts +LINES: 254 +========== +import { randomUUID } from 'crypto' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { queryModelWithoutStreaming } from '../../services/api/claude.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import { isGoalPromptHookCommand } from '../../goals/goalState.js' +import { createAttachmentMessage } from '../attachments.js' +import { createCombinedAbortSignal } from '../combinedAbortSignal.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import type { HookResult } from '../hooks.js' +import { safeParseJSON } from '../json.js' +import { createUserMessage, extractTextContent } from '../messages.js' +import { getSmallFastModel } from '../model/model.js' +import type { PromptHook } from '../settings/types.js' +import { asSystemPrompt } from '../systemPromptType.js' +import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js' + +function goalHookFailureResult( + hook: PromptHook, + reason: string, +): HookResult | null { + if (!isGoalPromptHookCommand(hook.prompt)) return null + + const blockingError = `Goal evaluator failed: ${reason}. Treat the goal as incomplete and continue working toward it.` + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError, + command: hook.prompt, + }, + preventContinuation: true, + stopReason: blockingError, + } +} + +/** + * Execute a prompt-based hook using an LLM + */ +export async function execPromptHook( + hook: PromptHook, + hookName: string, + hookEvent: HookEvent, + jsonInput: string, + signal: AbortSignal, + toolUseContext: ToolUseContext, + messages?: Message[], + toolUseID?: string, +): Promise { + // Use provided toolUseID or generate a new one + const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` + try { + // Replace $ARGUMENTS with the JSON input + const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) + logForDebugging( + `Hooks: Processing prompt hook with prompt: ${processedPrompt}`, + ) + + // Create user message directly - no need for processUserInput which would + // trigger UserPromptSubmit hooks and cause infinite recursion + const userMessage = createUserMessage({ content: processedPrompt }) + + // Prepend conversation history if provided + const messagesToQuery = + messages && messages.length > 0 + ? [...messages, userMessage] + : [userMessage] + + logForDebugging( + `Hooks: Querying model with ${messagesToQuery.length} messages`, + ) + + // Query the model with Haiku + const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000 + + // Combined signal: aborts if either the hook signal or timeout triggers + const { signal: combinedSignal, cleanup: cleanupSignal } = + createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) + + try { + const response = await queryModelWithoutStreaming({ + messages: messagesToQuery, + systemPrompt: asSystemPrompt([ + `You are evaluating a hook in Claude Code. + +Your response must be a JSON object matching one of the following schemas: +1. If the condition is met, return: {"ok": true} +2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`, + ]), + thinkingConfig: { type: 'disabled' as const }, + tools: toolUseContext.options.tools, + signal: combinedSignal, + options: { + async getToolPermissionContext() { + const appState = toolUseContext.getAppState() + return appState.toolPermissionContext + }, + model: hook.model ?? getSmallFastModel(), + toolChoice: undefined, + isNonInteractiveSession: true, + hasAppendSystemPrompt: false, + agents: [], + querySource: 'hook_prompt', + mcpTools: [], + agentId: toolUseContext.agentId, + outputFormat: { + type: 'json_schema', + schema: { + type: 'object', + properties: { + ok: { type: 'boolean' }, + reason: { type: 'string' }, + }, + required: ['ok'], + additionalProperties: false, + }, + }, + }, + }) + + cleanupSignal() + + // Extract text content from response + const content = extractTextContent(response.message.content) + + // Update response length for spinner display + toolUseContext.setResponseLength(length => length + content.length) + + const fullResponse = content.trim() + logForDebugging(`Hooks: Model response: ${fullResponse}`) + + const json = safeParseJSON(fullResponse) + if (!json) { + logForDebugging( + `Hooks: error parsing response as JSON: ${fullResponse}`, + ) + const goalFailure = goalHookFailureResult( + hook, + 'response was not valid JSON', + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: 'JSON validation failed', + stdout: fullResponse, + exitCode: 1, + }), + } + } + + const parsed = hookResponseSchema().safeParse(json) + if (!parsed.success) { + logForDebugging( + `Hooks: model response does not conform to expected schema: ${parsed.error.message}`, + ) + const goalFailure = goalHookFailureResult( + hook, + `response did not match the expected schema (${parsed.error.message})`, + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Schema validation failed: ${parsed.error.message}`, + stdout: fullResponse, + exitCode: 1, + }), + } + } + + // Failed to meet condition + if (!parsed.data.ok) { + logForDebugging( + `Hooks: Prompt hook condition was not met: ${parsed.data.reason}`, + ) + return { + hook, + outcome: 'blocking', + blockingError: { + blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`, + command: hook.prompt, + }, + preventContinuation: true, + stopReason: parsed.data.reason, + } + } + + // Condition was met + logForDebugging(`Hooks: Prompt hook condition was met`) + return { + hook, + outcome: 'success', + message: createAttachmentMessage({ + type: 'hook_success', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + content: '', + }), + } + } catch (error) { + cleanupSignal() + + if (combinedSignal.aborted) { + const goalFailure = signal.aborted + ? null + : goalHookFailureResult(hook, 'evaluation timed out') + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'cancelled', + } + } + throw error + } + } catch (error) { + const errorMsg = errorMessage(error) + logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`) + const goalFailure = goalHookFailureResult( + hook, + errorMsg || 'evaluation failed', + ) + if (goalFailure) return goalFailure + + return { + hook, + outcome: 'non_blocking_error', + message: createAttachmentMessage({ + type: 'hook_non_blocking_error', + hookName, + toolUseID: effectiveToolUseID, + hookEvent, + stderr: `Error executing prompt hook: ${errorMsg}`, + stdout: '', + exitCode: 1, + }), + } + } +} diff --git a/hook-dump/src_utils_hooks_fileChangedWatcher.ts b/hook-dump/src_utils_hooks_fileChangedWatcher.ts new file mode 100644 index 0000000000..8d5dec235f --- /dev/null +++ b/hook-dump/src_utils_hooks_fileChangedWatcher.ts @@ -0,0 +1,194 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\fileChangedWatcher.ts +LINES: 191 +========== +import chokidar, { type FSWatcher } from 'chokidar' +import { isAbsolute, join } from 'path' +import { registerCleanup } from '../cleanupRegistry.js' +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import { + executeCwdChangedHooks, + executeFileChangedHooks, + type HookOutsideReplResult, +} from '../hooks.js' +import { clearCwdEnvFiles } from '../sessionEnvironment.js' +import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js' + +let watcher: FSWatcher | null = null +let currentCwd: string +let dynamicWatchPaths: string[] = [] +let dynamicWatchPathsSorted: string[] = [] +let initialized = false +let hasEnvHooks = false +let notifyCallback: ((text: string, isError: boolean) => void) | null = null + +export function setEnvHookNotifier( + cb: ((text: string, isError: boolean) => void) | null, +): void { + notifyCallback = cb +} + +export function initializeFileChangedWatcher(cwd: string): void { + if (initialized) return + initialized = true + currentCwd = cwd + + const config = getHooksConfigFromSnapshot() + hasEnvHooks = + (config?.CwdChanged?.length ?? 0) > 0 || + (config?.FileChanged?.length ?? 0) > 0 + + if (hasEnvHooks) { + registerCleanup(async () => dispose()) + } + + const paths = resolveWatchPaths(config) + if (paths.length === 0) return + + startWatching(paths) +} + +function resolveWatchPaths( + config?: ReturnType, +): string[] { + const matchers = (config ?? getHooksConfigFromSnapshot())?.FileChanged ?? [] + + // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env") + const staticPaths: string[] = [] + for (const m of matchers) { + if (!m.matcher) continue + for (const name of m.matcher.split('|').map(s => s.trim())) { + if (!name) continue + staticPaths.push(isAbsolute(name) ? name : join(currentCwd, name)) + } + } + + // Combine static matcher paths with dynamic paths from hook output + return [...new Set([...staticPaths, ...dynamicWatchPaths])] +} + +function startWatching(paths: string[]): void { + logForDebugging(`FileChanged: watching ${paths.length} paths`) + watcher = chokidar.watch(paths, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 200 }, + ignorePermissionErrors: true, + }) + watcher.on('change', p => handleFileEvent(p, 'change')) + watcher.on('add', p => handleFileEvent(p, 'add')) + watcher.on('unlink', p => handleFileEvent(p, 'unlink')) +} + +function handleFileEvent( + path: string, + event: 'change' | 'add' | 'unlink', +): void { + logForDebugging(`FileChanged: ${event} ${path}`) + void executeFileChangedHooks(path, event) + .then(({ results, watchPaths, systemMessages }) => { + if (watchPaths.length > 0) { + updateWatchPaths(watchPaths) + } + for (const msg of systemMessages) { + notifyCallback?.(msg, false) + } + for (const r of results) { + if (!r.succeeded && r.output) { + notifyCallback?.(r.output, true) + } + } + }) + .catch(e => { + const msg = errorMessage(e) + logForDebugging(`FileChanged hook failed: ${msg}`, { + level: 'error', + }) + notifyCallback?.(msg, true) + }) +} + +export function updateWatchPaths(paths: string[]): void { + if (!initialized) return + const sorted = paths.slice().sort() + if ( + sorted.length === dynamicWatchPathsSorted.length && + sorted.every((p, i) => p === dynamicWatchPathsSorted[i]) + ) { + return + } + dynamicWatchPaths = paths + dynamicWatchPathsSorted = sorted + restartWatching() +} + +function restartWatching(): void { + if (watcher) { + void watcher.close() + watcher = null + } + const paths = resolveWatchPaths() + if (paths.length > 0) { + startWatching(paths) + } +} + +export async function onCwdChangedForHooks( + oldCwd: string, + newCwd: string, +): Promise { + if (oldCwd === newCwd) return + + // Re-evaluate from the current snapshot so mid-session hook changes are picked up + const config = getHooksConfigFromSnapshot() + const currentHasEnvHooks = + (config?.CwdChanged?.length ?? 0) > 0 || + (config?.FileChanged?.length ?? 0) > 0 + if (!currentHasEnvHooks) return + currentCwd = newCwd + + await clearCwdEnvFiles() + const hookResult = await executeCwdChangedHooks(oldCwd, newCwd).catch(e => { + const msg = errorMessage(e) + logForDebugging(`CwdChanged hook failed: ${msg}`, { + level: 'error', + }) + notifyCallback?.(msg, true) + return { + results: [] as HookOutsideReplResult[], + watchPaths: [] as string[], + systemMessages: [] as string[], + } + }) + dynamicWatchPaths = hookResult.watchPaths + dynamicWatchPathsSorted = hookResult.watchPaths.slice().sort() + for (const msg of hookResult.systemMessages) { + notifyCallback?.(msg, false) + } + for (const r of hookResult.results) { + if (!r.succeeded && r.output) { + notifyCallback?.(r.output, true) + } + } + + // Re-resolve matcher paths against the new cwd + if (initialized) { + restartWatching() + } +} + +function dispose(): void { + if (watcher) { + void watcher.close() + watcher = null + } + dynamicWatchPaths = [] + dynamicWatchPathsSorted = [] + initialized = false + hasEnvHooks = false + notifyCallback = null +} + +export function resetFileChangedWatcherForTesting(): void { + dispose() +} diff --git a/hook-dump/src_utils_hooks_hookEvents.ts b/hook-dump/src_utils_hooks_hookEvents.ts new file mode 100644 index 0000000000..5474812689 --- /dev/null +++ b/hook-dump/src_utils_hooks_hookEvents.ts @@ -0,0 +1,195 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hookEvents.ts +LINES: 192 +========== +/** + * Hook event system for broadcasting hook execution events. + * + * This module provides a generic event system that is separate from the + * main message stream. Handlers can register to receive events and decide + * what to do with them (e.g., convert to SDK messages, log, etc.). + */ + +import { HOOK_EVENTS } from 'src/entrypoints/sdk/coreTypes.js' + +import { logForDebugging } from '../debug.js' + +/** + * Hook events that are always emitted regardless of the includeHookEvents + * option. These are low-noise lifecycle events that were in the original + * allowlist and are backwards-compatible. + */ +const ALWAYS_EMITTED_HOOK_EVENTS = ['SessionStart', 'Setup'] as const + +const MAX_PENDING_EVENTS = 100 + +export type HookStartedEvent = { + type: 'started' + hookId: string + hookName: string + hookEvent: string +} + +export type HookProgressEvent = { + type: 'progress' + hookId: string + hookName: string + hookEvent: string + stdout: string + stderr: string + output: string +} + +export type HookResponseEvent = { + type: 'response' + hookId: string + hookName: string + hookEvent: string + output: string + stdout: string + stderr: string + exitCode?: number + outcome: 'success' | 'error' | 'cancelled' +} + +export type HookExecutionEvent = + | HookStartedEvent + | HookProgressEvent + | HookResponseEvent +export type HookEventHandler = (event: HookExecutionEvent) => void + +const pendingEvents: HookExecutionEvent[] = [] +let eventHandler: HookEventHandler | null = null +let allHookEventsEnabled = false + +export function registerHookEventHandler( + handler: HookEventHandler | null, +): void { + eventHandler = handler + if (handler && pendingEvents.length > 0) { + for (const event of pendingEvents.splice(0)) { + handler(event) + } + } +} + +function emit(event: HookExecutionEvent): void { + if (eventHandler) { + eventHandler(event) + } else { + pendingEvents.push(event) + if (pendingEvents.length > MAX_PENDING_EVENTS) { + pendingEvents.shift() + } + } +} + +function shouldEmit(hookEvent: string): boolean { + if ((ALWAYS_EMITTED_HOOK_EVENTS as readonly string[]).includes(hookEvent)) { + return true + } + return ( + allHookEventsEnabled && + (HOOK_EVENTS as readonly string[]).includes(hookEvent) + ) +} + +export function emitHookStarted( + hookId: string, + hookName: string, + hookEvent: string, +): void { + if (!shouldEmit(hookEvent)) return + + emit({ + type: 'started', + hookId, + hookName, + hookEvent, + }) +} + +export function emitHookProgress(data: { + hookId: string + hookName: string + hookEvent: string + stdout: string + stderr: string + output: string +}): void { + if (!shouldEmit(data.hookEvent)) return + + emit({ + type: 'progress', + ...data, + }) +} + +export function startHookProgressInterval(params: { + hookId: string + hookName: string + hookEvent: string + getOutput: () => Promise<{ stdout: string; stderr: string; output: string }> + intervalMs?: number +}): () => void { + if (!shouldEmit(params.hookEvent)) return () => {} + + let lastEmittedOutput = '' + const interval = setInterval(() => { + void params.getOutput().then(({ stdout, stderr, output }) => { + if (output === lastEmittedOutput) return + lastEmittedOutput = output + emitHookProgress({ + hookId: params.hookId, + hookName: params.hookName, + hookEvent: params.hookEvent, + stdout, + stderr, + output, + }) + }) + }, params.intervalMs ?? 1000) + interval.unref() + + return () => clearInterval(interval) +} + +export function emitHookResponse(data: { + hookId: string + hookName: string + hookEvent: string + output: string + stdout: string + stderr: string + exitCode?: number + outcome: 'success' | 'error' | 'cancelled' +}): void { + // Always log full hook output to debug log for verbose mode debugging + const outputToLog = data.stdout || data.stderr || data.output + if (outputToLog) { + logForDebugging( + `Hook ${data.hookName} (${data.hookEvent}) ${data.outcome}:\n${outputToLog}`, + ) + } + + if (!shouldEmit(data.hookEvent)) return + + emit({ + type: 'response', + ...data, + }) +} + +/** + * Enable emission of all hook event types (beyond SessionStart and Setup). + * Called when the SDK `includeHookEvents` option is set or when running + * in CLAUDE_CODE_REMOTE mode. + */ +export function setAllHookEventsEnabled(enabled: boolean): void { + allHookEventsEnabled = enabled +} + +export function clearHookEventState(): void { + eventHandler = null + pendingEvents.length = 0 + allHookEventsEnabled = false +} diff --git a/hook-dump/src_utils_hooks_hookHelpers.ts b/hook-dump/src_utils_hooks_hookHelpers.ts new file mode 100644 index 0000000000..1326d115df --- /dev/null +++ b/hook-dump/src_utils_hooks_hookHelpers.ts @@ -0,0 +1,86 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hookHelpers.ts +LINES: 83 +========== +import { z } from 'zod/v4' +import type { Tool } from '../../Tool.js' +import { + SYNTHETIC_OUTPUT_TOOL_NAME, + SyntheticOutputTool, +} from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' +import { substituteArguments } from '../argumentSubstitution.js' +import { lazySchema } from '../lazySchema.js' +import type { SetAppState } from '../messageQueueManager.js' +import { hasSuccessfulToolCall } from '../messages.js' +import { addFunctionHook } from './sessionHooks.js' + +/** + * Schema for hook responses (shared by prompt and agent hooks) + */ +export const hookResponseSchema = lazySchema(() => + z.object({ + ok: z.boolean().describe('Whether the condition was met'), + reason: z + .string() + .describe('Reason, if the condition was not met') + .optional(), + }), +) + +/** + * Add hook input JSON to prompt, either replacing $ARGUMENTS placeholder or appending. + * Also supports indexed arguments like $ARGUMENTS[0], $ARGUMENTS[1], or shorthand $0, $1, etc. + */ +export function addArgumentsToPrompt( + prompt: string, + jsonInput: string, +): string { + return substituteArguments(prompt, jsonInput) +} + +/** + * Create a StructuredOutput tool configured for hook responses. + * Reusable by agent hooks and background verification. + */ +export function createStructuredOutputTool(): Tool { + return { + ...SyntheticOutputTool, + inputSchema: hookResponseSchema(), + inputJSONSchema: { + type: 'object', + properties: { + ok: { + type: 'boolean', + description: 'Whether the condition was met', + }, + reason: { + type: 'string', + description: 'Reason, if the condition was not met', + }, + }, + required: ['ok'], + additionalProperties: false, + }, + async prompt(): Promise { + return `Use this tool to return your verification result. You MUST call this tool exactly once at the end of your response.` + }, + } +} + +/** + * Register a function hook that enforces structured output via SyntheticOutputTool. + * Used by ask.tsx, execAgentHook.ts, and background verification. + */ +export function registerStructuredOutputEnforcement( + setAppState: SetAppState, + sessionId: string, +): void { + addFunctionHook( + setAppState, + sessionId, + 'Stop', + '', // No matcher - applies to all stops + messages => hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME), + `You MUST call the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool to complete this request. Call this tool now.`, + { timeout: 5000 }, + ) +} diff --git a/hook-dump/src_utils_hooks_hooksConfigManager.ts b/hook-dump/src_utils_hooks_hooksConfigManager.ts new file mode 100644 index 0000000000..10615ea57a --- /dev/null +++ b/hook-dump/src_utils_hooks_hooksConfigManager.ts @@ -0,0 +1,403 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigManager.ts +LINES: 400 +========== +import memoize from 'lodash-es/memoize.js' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { getRegisteredHooks } from '../../bootstrap/state.js' +import type { AppState } from '../../state/AppState.js' +import { + getAllHooks, + type IndividualHookConfig, + sortMatchersByPriority, +} from './hooksSettings.js' + +export type MatcherMetadata = { + fieldToMatch: string + values: string[] +} + +export type HookEventMetadata = { + summary: string + description: string + matcherMetadata?: MatcherMetadata +} + +// Hook event metadata configuration. +// Resolver uses sorted-joined string key so that callers passing a fresh +// toolNames array each render (e.g. HooksConfigMenu) hit the cache instead +// of leaking a new entry per call. +export const getHookEventMetadata = memoize( + function (toolNames: string[]): Record { + return { + PreToolUse: { + summary: 'Before tool execution', + description: + 'Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PostToolUse: { + summary: 'After tool execution', + description: + 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PostToolUseFailure: { + summary: 'After tool execution fails', + description: + 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + PermissionDenied: { + summary: 'After auto mode classifier denies a tool call', + description: + 'Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {"hookSpecificOutput":{"hookEventName":"PermissionDenied","retry":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + Notification: { + summary: 'When notifications are sent', + description: + 'Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'notification_type', + values: [ + 'permission_prompt', + 'idle_prompt', + 'auth_success', + 'elicitation_dialog', + 'elicitation_complete', + 'elicitation_response', + ], + }, + }, + UserPromptSubmit: { + summary: 'When the user submits a prompt', + description: + 'Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only', + }, + SessionStart: { + summary: 'When a new session is started', + description: + 'Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'source', + values: ['startup', 'resume', 'clear', 'compact'], + }, + }, + Stop: { + summary: 'Right before Claude concludes its response', + description: + 'Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only', + }, + StopFailure: { + summary: 'When the turn ends due to an API error', + description: + 'Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget 鈥?hook output and exit codes are ignored.', + matcherMetadata: { + fieldToMatch: 'error', + values: [ + 'rate_limit', + 'authentication_failed', + 'billing_error', + 'invalid_request', + 'server_error', + 'max_output_tokens', + 'unknown', + ], + }, + }, + SubagentStart: { + summary: 'When a subagent (Agent tool call) is started', + description: + 'Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'agent_type', + values: [], // Will be populated with available agent types + }, + }, + SubagentStop: { + summary: + 'Right before a subagent (Agent tool call) concludes its response', + description: + 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'agent_type', + values: [], // Will be populated with available agent types + }, + }, + PreCompact: { + summary: 'Before conversation compaction', + description: + 'Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['manual', 'auto'], + }, + }, + PostCompact: { + summary: 'After conversation compaction', + description: + 'Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['manual', 'auto'], + }, + }, + SessionEnd: { + summary: 'When a session is ending', + description: + 'Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'reason', + values: ['clear', 'logout', 'prompt_input_exit', 'other'], + }, + }, + PermissionRequest: { + summary: 'When a permission dialog is displayed', + description: + 'Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'tool_name', + values: toolNames, + }, + }, + Setup: { + summary: 'Repo setup hooks for init and maintenance', + description: + 'Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'trigger', + values: ['init', 'maintenance'], + }, + }, + TeammateIdle: { + summary: 'When a teammate is about to go idle', + description: + 'Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only', + }, + TaskCreated: { + summary: 'When a task is being created', + description: + 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only', + }, + TaskCompleted: { + summary: 'When a task is being marked as completed', + description: + 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only', + }, + Elicitation: { + summary: 'When an MCP server requests user input (elicitation)', + description: + 'Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'mcp_server_name', + values: [], + }, + }, + ElicitationResult: { + summary: 'After a user responds to an MCP elicitation', + description: + 'Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'mcp_server_name', + values: [], + }, + }, + ConfigChange: { + summary: 'When configuration files change during a session', + description: + 'Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only', + matcherMetadata: { + fieldToMatch: 'source', + values: [ + 'user_settings', + 'project_settings', + 'local_settings', + 'policy_settings', + 'skills', + ], + }, + }, + InstructionsLoaded: { + summary: 'When an instruction file (CLAUDE.md or rule) is loaded', + description: + 'Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional 鈥?the paths: frontmatter patterns that matched), trigger_file_path (optional 鈥?the file Claude touched that caused the load), and parent_file_path (optional 鈥?the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.', + matcherMetadata: { + fieldToMatch: 'load_reason', + values: [ + 'session_start', + 'nested_traversal', + 'path_glob_match', + 'include', + 'compact', + ], + }, + }, + WorktreeCreate: { + summary: 'Create an isolated worktree for VCS-agnostic isolation', + description: + 'Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed', + }, + WorktreeRemove: { + summary: 'Remove a previously created worktree', + description: + 'Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only', + }, + CwdChanged: { + summary: 'After the working directory changes', + description: + 'Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + }, + FileChanged: { + summary: 'When a watched file changes', + description: + 'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', + }, + } + }, + toolNames => toolNames.slice().sort().join(','), +) + +// Group hooks by event and matcher +export function groupHooksByEventAndMatcher( + appState: AppState, + toolNames: string[], +): Record> { + const grouped: Record> = { + PreToolUse: {}, + PostToolUse: {}, + PostToolUseFailure: {}, + PermissionDenied: {}, + Notification: {}, + UserPromptSubmit: {}, + SessionStart: {}, + SessionEnd: {}, + Stop: {}, + StopFailure: {}, + SubagentStart: {}, + SubagentStop: {}, + PreCompact: {}, + PostCompact: {}, + PermissionRequest: {}, + Setup: {}, + TeammateIdle: {}, + TaskCreated: {}, + TaskCompleted: {}, + Elicitation: {}, + ElicitationResult: {}, + ConfigChange: {}, + WorktreeCreate: {}, + WorktreeRemove: {}, + InstructionsLoaded: {}, + CwdChanged: {}, + FileChanged: {}, + } + + const metadata = getHookEventMetadata(toolNames) + + // Include hooks from settings files + getAllHooks(appState).forEach(hook => { + const eventGroup = grouped[hook.event] + if (eventGroup) { + // For events without matchers, use empty string as key + const matcherKey = + metadata[hook.event].matcherMetadata !== undefined + ? hook.matcher || '' + : '' + if (!eventGroup[matcherKey]) { + eventGroup[matcherKey] = [] + } + eventGroup[matcherKey].push(hook) + } + }) + + // Include registered hooks (e.g., plugin hooks) + const registeredHooks = getRegisteredHooks() + if (registeredHooks) { + for (const [event, matchers] of Object.entries(registeredHooks)) { + const hookEvent = event as HookEvent + const eventGroup = grouped[hookEvent] + if (!eventGroup) continue + + for (const matcher of matchers) { + const matcherKey = matcher.matcher || '' + + // Only PluginHookMatcher has pluginRoot; HookCallbackMatcher (internal + // callbacks like attributionHooks, sessionFileAccessHooks) does not. + if ('pluginRoot' in matcher) { + eventGroup[matcherKey] ??= [] + for (const hook of matcher.hooks) { + eventGroup[matcherKey].push({ + event: hookEvent, + config: hook, + matcher: matcher.matcher, + source: 'pluginHook', + pluginName: matcher.pluginId, + }) + } + } else if (process.env.USER_TYPE === 'ant') { + eventGroup[matcherKey] ??= [] + for (const _hook of matcher.hooks) { + eventGroup[matcherKey].push({ + event: hookEvent, + config: { + type: 'command', + command: '[ANT-ONLY] Built-in Hook', + }, + matcher: matcher.matcher, + source: 'builtinHook', + }) + } + } + } + } + } + + return grouped +} + +// Get sorted matchers for a specific event +export function getSortedMatchersForEvent( + hooksByEventAndMatcher: Record< + HookEvent, + Record + >, + event: HookEvent, +): string[] { + const matchers = Object.keys(hooksByEventAndMatcher[event] || {}) + return sortMatchersByPriority(matchers, hooksByEventAndMatcher, event) +} + +// Get hooks for a specific event and matcher +export function getHooksForMatcher( + hooksByEventAndMatcher: Record< + HookEvent, + Record + >, + event: HookEvent, + matcher: string | null, +): IndividualHookConfig[] { + // For events without matchers, hooks are stored with empty string as key + // because the record keys must be strings. + const matcherKey = matcher ?? '' + return hooksByEventAndMatcher[event]?.[matcherKey] ?? [] +} + +// Get metadata for a specific event's matcher +export function getMatcherMetadata( + event: HookEvent, + toolNames: string[], +): MatcherMetadata | undefined { + return getHookEventMetadata(toolNames)[event].matcherMetadata +} diff --git a/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts b/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts new file mode 100644 index 0000000000..49beebcc9e --- /dev/null +++ b/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts @@ -0,0 +1,136 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigSnapshot.ts +LINES: 133 +========== +import { resetSdkInitState } from '../../bootstrap/state.js' +import { isRestrictedToPluginOnly } from '../settings/pluginOnlyPolicy.js' +// Import as module object so spyOn works in tests (direct imports bypass spies) +import * as settingsModule from '../settings/settings.js' +import { resetSettingsCache } from '../settings/settingsCache.js' +import type { HooksSettings } from '../settings/types.js' + +let initialHooksConfig: HooksSettings | null = null + +/** + * Get hooks from allowed sources. + * If allowManagedHooksOnly is set in policySettings, only managed hooks are returned. + * If disableAllHooks is set in policySettings, no hooks are returned. + * If disableAllHooks is set in non-managed settings, only managed hooks are returned + * (non-managed settings cannot disable managed hooks). + * Otherwise, returns merged hooks from all sources (backwards compatible). + */ +function getHooksFromAllowedSources(): HooksSettings { + const policySettings = settingsModule.getSettingsForSource('policySettings') + + // If managed settings disables all hooks, return empty + if (policySettings?.disableAllHooks === true) { + return {} + } + + // If allowManagedHooksOnly is set in managed settings, only use managed hooks + if (policySettings?.allowManagedHooksOnly === true) { + return policySettings.hooks ?? {} + } + + // strictPluginOnlyCustomization: block user/project/local settings hooks. + // Plugin hooks (registered channel, hooks.ts:1391) are NOT affected 鈥? + // they're assembled separately and the managedOnly skip there is keyed + // on shouldAllowManagedHooksOnly(), not on this policy. Agent frontmatter + // hooks are gated at REGISTRATION (runAgent.ts:~535) by agent source 鈥? + // plugin/built-in/policySettings agents register normally, user-sourced + // agents skip registration under ["hooks"]. A blanket execution-time + // block here would over-kill plugin agents' hooks. + if (isRestrictedToPluginOnly('hooks')) { + return policySettings?.hooks ?? {} + } + + const mergedSettings = settingsModule.getSettings_DEPRECATED() + + // If disableAllHooks is set in non-managed settings, only managed hooks still run + // (non-managed settings cannot override managed hooks) + if (mergedSettings.disableAllHooks === true) { + return policySettings?.hooks ?? {} + } + + // Otherwise, use all hooks (merged from all sources) - backwards compatible + return mergedSettings.hooks ?? {} +} + +/** + * Check if only managed hooks should run. + * This is true when: + * - policySettings has allowManagedHooksOnly: true, OR + * - disableAllHooks is set in non-managed settings (non-managed settings + * cannot disable managed hooks, so they effectively become managed-only) + */ +export function shouldAllowManagedHooksOnly(): boolean { + const policySettings = settingsModule.getSettingsForSource('policySettings') + if (policySettings?.allowManagedHooksOnly === true) { + return true + } + // If disableAllHooks is set but NOT from managed settings, + // treat as managed-only (non-managed hooks disabled, managed hooks still run) + if ( + settingsModule.getSettings_DEPRECATED().disableAllHooks === true && + policySettings?.disableAllHooks !== true + ) { + return true + } + return false +} + +/** + * Check if all hooks (including managed) should be disabled. + * This is only true when managed/policy settings has disableAllHooks: true. + * When disableAllHooks is set in non-managed settings, managed hooks still run. + */ +export function shouldDisableAllHooksIncludingManaged(): boolean { + return ( + settingsModule.getSettingsForSource('policySettings')?.disableAllHooks === + true + ) +} + +/** + * Capture a snapshot of the current hooks configuration + * This should be called once during application startup + * Respects the allowManagedHooksOnly setting + */ +export function captureHooksConfigSnapshot(): void { + initialHooksConfig = getHooksFromAllowedSources() +} + +/** + * Update the hooks configuration snapshot + * This should be called when hooks are modified through the settings + * Respects the allowManagedHooksOnly setting + */ +export function updateHooksConfigSnapshot(): void { + // Reset the session cache to ensure we read fresh settings from disk. + // Without this, the snapshot could use stale cached settings when the user + // edits settings.json externally and then runs /hooks - the session cache + // may not have been invalidated yet (e.g., if the file watcher's stability + // threshold hasn't elapsed). + resetSettingsCache() + initialHooksConfig = getHooksFromAllowedSources() +} + +/** + * Get the current hooks configuration from snapshot + * Falls back to settings if no snapshot exists + * @returns The hooks configuration + */ +export function getHooksConfigFromSnapshot(): HooksSettings | null { + if (initialHooksConfig === null) { + captureHooksConfigSnapshot() + } + return initialHooksConfig +} + +/** + * Reset the hooks configuration snapshot (useful for testing) + * Also resets SDK init state to prevent test pollution + */ +export function resetHooksConfigSnapshot(): void { + initialHooksConfig = null + resetSdkInitState() +} diff --git a/hook-dump/src_utils_hooks_hooksSettings.ts b/hook-dump/src_utils_hooks_hooksSettings.ts new file mode 100644 index 0000000000..11ea49da2e --- /dev/null +++ b/hook-dump/src_utils_hooks_hooksSettings.ts @@ -0,0 +1,274 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksSettings.ts +LINES: 271 +========== +import { resolve } from 'path' +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import { getSessionId } from '../../bootstrap/state.js' +import type { AppState } from '../../state/AppState.js' +import type { EditableSettingSource } from '../settings/constants.js' +import { SOURCES } from '../settings/constants.js' +import { + getSettingsFilePathForSource, + getSettingsForSource, +} from '../settings/settings.js' +import type { HookCommand, HookMatcher } from '../settings/types.js' +import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' +import { getSessionHooks } from './sessionHooks.js' + +export type HookSource = + | EditableSettingSource + | 'policySettings' + | 'pluginHook' + | 'sessionHook' + | 'builtinHook' + +export interface IndividualHookConfig { + event: HookEvent + config: HookCommand + matcher?: string + source: HookSource + pluginName?: string +} + +/** + * Check if two hooks are equal (comparing only command/prompt content, not timeout) + */ +export function isHookEqual( + a: HookCommand | { type: 'function'; timeout?: number }, + b: HookCommand | { type: 'function'; timeout?: number }, +): boolean { + if (a.type !== b.type) return false + + // Use switch for exhaustive type checking + // Note: We only compare command/prompt content, not timeout + // `if` is part of identity: same command with different `if` conditions + // are distinct hooks (e.g., setup.sh if=Bash(git *) vs if=Bash(npm *)). + const sameIf = (x: { if?: string }, y: { if?: string }) => + (x.if ?? '') === (y.if ?? '') + switch (a.type) { + case 'command': + // shell is part of identity: same command string with different + // shells are distinct hooks. Default 'bash' so undefined === 'bash'. + return ( + b.type === 'command' && + a.command === b.command && + (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) && + sameIf(a, b) + ) + case 'prompt': + return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b) + case 'agent': + return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b) + case 'http': + return b.type === 'http' && a.url === b.url && sameIf(a, b) + case 'function': + // Function hooks can't be compared (no stable identifier) + return false + } +} + +/** Get the display text for a hook */ +export function getHookDisplayText( + hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string }, +): string { + // Return custom status message if provided + if ('statusMessage' in hook && hook.statusMessage) { + return hook.statusMessage + } + + switch (hook.type) { + case 'command': + return hook.command + case 'prompt': + return hook.prompt + case 'agent': + return hook.prompt + case 'http': + return hook.url + case 'callback': + return 'callback' + case 'function': + return 'function' + } +} + +export function getAllHooks(appState: AppState): IndividualHookConfig[] { + const hooks: IndividualHookConfig[] = [] + + // Check if restricted to managed hooks only + const policySettings = getSettingsForSource('policySettings') + const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true + + // If allowManagedHooksOnly is set, don't show any hooks in the UI + // (user/project/local are blocked, and managed hooks are intentionally hidden) + if (!restrictedToManagedOnly) { + // Get hooks from all editable sources + const sources = [ + 'userSettings', + 'projectSettings', + 'localSettings', + ] as EditableSettingSource[] + + // Track which settings files we've already processed to avoid duplicates + // (e.g., when running from home directory, userSettings and projectSettings + // both resolve to ~/.claude/settings.json) + const seenFiles = new Set() + + for (const source of sources) { + const filePath = getSettingsFilePathForSource(source) + if (filePath) { + const resolvedPath = resolve(filePath) + if (seenFiles.has(resolvedPath)) { + continue + } + seenFiles.add(resolvedPath) + } + + const sourceSettings = getSettingsForSource(source) + if (!sourceSettings?.hooks) { + continue + } + + for (const [event, matchers] of Object.entries(sourceSettings.hooks)) { + for (const matcher of matchers as HookMatcher[]) { + for (const hookCommand of matcher.hooks) { + hooks.push({ + event: event as HookEvent, + config: hookCommand, + matcher: matcher.matcher, + source, + }) + } + } + } + } + } + + // Get session hooks + const sessionId = getSessionId() + const sessionHooks = getSessionHooks(appState, sessionId) + for (const [event, matchers] of sessionHooks.entries()) { + for (const matcher of matchers) { + for (const hookCommand of matcher.hooks) { + hooks.push({ + event, + config: hookCommand, + matcher: matcher.matcher, + source: 'sessionHook', + }) + } + } + } + + return hooks +} + +export function getHooksForEvent( + appState: AppState, + event: HookEvent, +): IndividualHookConfig[] { + return getAllHooks(appState).filter(hook => hook.event === event) +} + +export function hookSourceDescriptionDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User settings (~/.claude/settings.json)' + case 'projectSettings': + return 'Project settings (.claude/settings.json)' + case 'localSettings': + return 'Local settings (.claude/settings.local.json)' + case 'pluginHook': + // TODO: Get the actual plugin hook file paths instead of using glob pattern + // We should capture the specific plugin paths during hook registration and display them here + // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" + return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' + case 'sessionHook': + return 'Session hooks (in-memory, temporary)' + case 'builtinHook': + return 'Built-in hooks (registered internally by Claude Code)' + default: + return source as string + } +} + +export function hookSourceHeaderDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User Settings' + case 'projectSettings': + return 'Project Settings' + case 'localSettings': + return 'Local Settings' + case 'pluginHook': + return 'Plugin Hooks' + case 'sessionHook': + return 'Session Hooks' + case 'builtinHook': + return 'Built-in Hooks' + default: + return source as string + } +} + +export function hookSourceInlineDisplayString(source: HookSource): string { + switch (source) { + case 'userSettings': + return 'User' + case 'projectSettings': + return 'Project' + case 'localSettings': + return 'Local' + case 'pluginHook': + return 'Plugin' + case 'sessionHook': + return 'Session' + case 'builtinHook': + return 'Built-in' + default: + return source as string + } +} + +export function sortMatchersByPriority( + matchers: string[], + hooksByEventAndMatcher: Record< + string, + Record + >, + selectedEvent: HookEvent, +): string[] { + // Create a priority map based on SOURCES order (lower index = higher priority) + const sourcePriority = SOURCES.reduce( + (acc, source, index) => { + acc[source] = index + return acc + }, + {} as Record, + ) + + return [...matchers].sort((a, b) => { + const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || [] + const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || [] + + const aSources = Array.from(new Set(aHooks.map(h => h.source))) + const bSources = Array.from(new Set(bHooks.map(h => h.source))) + + // Sort by highest priority source first (lowest priority number) + // Plugin hooks get lowest priority (highest number) + const getSourcePriority = (source: HookSource) => + source === 'pluginHook' || source === 'builtinHook' + ? 999 + : sourcePriority[source as EditableSettingSource] + + const aHighestPriority = Math.min(...aSources.map(getSourcePriority)) + const bHighestPriority = Math.min(...bSources.map(getSourcePriority)) + + if (aHighestPriority !== bHighestPriority) { + return aHighestPriority - bHighestPriority + } + + // If same priority, sort by matcher name + return a.localeCompare(b) + }) +} diff --git a/hook-dump/src_utils_hooks_postSamplingHooks.ts b/hook-dump/src_utils_hooks_postSamplingHooks.ts new file mode 100644 index 0000000000..b2541a3f5c --- /dev/null +++ b/hook-dump/src_utils_hooks_postSamplingHooks.ts @@ -0,0 +1,73 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\postSamplingHooks.ts +LINES: 70 +========== +import type { QuerySource } from '../../constants/querySource.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import { toError } from '../errors.js' +import { logError } from '../log.js' +import type { SystemPrompt } from '../systemPromptType.js' + +// Post-sampling hook - not exposed in settings.json config (yet), only used programmatically + +// Generic context for REPL hooks (both post-sampling and stop hooks) +export type REPLHookContext = { + messages: Message[] // Full message history including assistant responses + systemPrompt: SystemPrompt + userContext: { [k: string]: string } + systemContext: { [k: string]: string } + toolUseContext: ToolUseContext + querySource?: QuerySource +} + +export type PostSamplingHook = ( + context: REPLHookContext, +) => Promise | void + +// Internal registry for post-sampling hooks +const postSamplingHooks: PostSamplingHook[] = [] + +/** + * Register a post-sampling hook that will be called after model sampling completes + * This is an internal API not exposed through settings + */ +export function registerPostSamplingHook(hook: PostSamplingHook): void { + postSamplingHooks.push(hook) +} + +/** + * Clear all registered post-sampling hooks (for testing) + */ +export function clearPostSamplingHooks(): void { + postSamplingHooks.length = 0 +} + +/** + * Execute all registered post-sampling hooks + */ +export async function executePostSamplingHooks( + messages: Message[], + systemPrompt: SystemPrompt, + userContext: { [k: string]: string }, + systemContext: { [k: string]: string }, + toolUseContext: ToolUseContext, + querySource?: QuerySource, +): Promise { + const context: REPLHookContext = { + messages, + systemPrompt, + userContext, + systemContext, + toolUseContext, + querySource, + } + + for (const hook of postSamplingHooks) { + try { + await hook(context) + } catch (error) { + // Log but don't fail on hook errors + logError(toError(error)) + } + } +} diff --git a/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts b/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts new file mode 100644 index 0000000000..d9744dc28a --- /dev/null +++ b/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts @@ -0,0 +1,70 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerFrontmatterHooks.ts +LINES: 67 +========== +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import { logForDebugging } from '../debug.js' +import type { HooksSettings } from '../settings/types.js' +import { addSessionHook } from './sessionHooks.js' + +/** + * Register hooks from frontmatter (agent or skill) into session-scoped hooks. + * These hooks will be active for the duration of the session/agent and cleaned up + * when the session/agent ends. + * + * @param setAppState Function to update app state + * @param sessionId Session ID to scope the hooks (agent ID for agents, session ID for skills) + * @param hooks The hooks settings from frontmatter + * @param sourceName Human-readable source name for logging (e.g., "agent 'my-agent'") + * @param isAgent If true, converts Stop hooks to SubagentStop (since subagents trigger SubagentStop, not Stop) + */ +export function registerFrontmatterHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + hooks: HooksSettings, + sourceName: string, + isAgent: boolean = false, +): void { + if (!hooks || Object.keys(hooks).length === 0) { + return + } + + let hookCount = 0 + + for (const event of HOOK_EVENTS) { + const matchers = hooks[event] + if (!matchers || matchers.length === 0) { + continue + } + + // For agents, convert Stop hooks to SubagentStop since that's what fires when an agent completes + // (executeStopHooks uses SubagentStop when called with an agentId) + let targetEvent: HookEvent = event + if (isAgent && event === 'Stop') { + targetEvent = 'SubagentStop' + logForDebugging( + `Converting Stop hook to SubagentStop for ${sourceName} (subagents trigger SubagentStop)`, + ) + } + + for (const matcherConfig of matchers) { + const matcher = matcherConfig.matcher ?? '' + const hooksArray = matcherConfig.hooks + + if (!hooksArray || hooksArray.length === 0) { + continue + } + + for (const hook of hooksArray) { + addSessionHook(setAppState, sessionId, targetEvent, matcher, hook) + hookCount++ + } + } + } + + if (hookCount > 0) { + logForDebugging( + `Registered ${hookCount} frontmatter hook(s) from ${sourceName} for session ${sessionId}`, + ) + } +} diff --git a/hook-dump/src_utils_hooks_registerSkillHooks.ts b/hook-dump/src_utils_hooks_registerSkillHooks.ts new file mode 100644 index 0000000000..bd7abb360b --- /dev/null +++ b/hook-dump/src_utils_hooks_registerSkillHooks.ts @@ -0,0 +1,67 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerSkillHooks.ts +LINES: 64 +========== +import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import { logForDebugging } from '../debug.js' +import type { HooksSettings } from '../settings/types.js' +import { addSessionHook, removeSessionHook } from './sessionHooks.js' + +/** + * Registers hooks from a skill's frontmatter as session hooks. + * + * Hooks are registered as session-scoped hooks that persist for the duration + * of the session. If a hook has `once: true`, it will be automatically removed + * after its first successful execution. + * + * @param setAppState - Function to update the app state + * @param sessionId - The current session ID + * @param hooks - The hooks settings from the skill's frontmatter + * @param skillName - The name of the skill (for logging) + * @param skillRoot - The base directory of the skill (for CLAUDE_PLUGIN_ROOT env var) + */ +export function registerSkillHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + hooks: HooksSettings, + skillName: string, + skillRoot?: string, +): void { + let registeredCount = 0 + + for (const eventName of HOOK_EVENTS) { + const matchers = hooks[eventName] + if (!matchers) continue + + for (const matcher of matchers) { + for (const hook of matcher.hooks) { + // For once: true hooks, use onHookSuccess callback to remove after execution + const onHookSuccess = hook.once + ? () => { + logForDebugging( + `Removing one-shot hook for event ${eventName} in skill '${skillName}'`, + ) + removeSessionHook(setAppState, sessionId, eventName, hook) + } + : undefined + + addSessionHook( + setAppState, + sessionId, + eventName, + matcher.matcher || '', + hook, + onHookSuccess, + skillRoot, + ) + registeredCount++ + } + } + } + + if (registeredCount > 0) { + logForDebugging( + `Registered ${registeredCount} hooks from skill '${skillName}'`, + ) + } +} diff --git a/hook-dump/src_utils_hooks_sessionHooks.ts b/hook-dump/src_utils_hooks_sessionHooks.ts new file mode 100644 index 0000000000..7e41ac1eb2 --- /dev/null +++ b/hook-dump/src_utils_hooks_sessionHooks.ts @@ -0,0 +1,450 @@ +FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\sessionHooks.ts +LINES: 447 +========== +import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' +import type { AppState } from 'src/state/AppState.js' +import type { Message } from 'src/types/message.js' +import { logForDebugging } from '../debug.js' +import type { AggregatedHookResult } from '../hooks.js' +import type { HookCommand } from '../settings/types.js' +import { isHookEqual } from './hooksSettings.js' + +type OnHookSuccess = ( + hook: HookCommand | FunctionHook, + result: AggregatedHookResult, +) => void + +/** Function hook callback - returns true if check passes, false to block */ +export type FunctionHookCallback = ( + messages: Message[], + signal?: AbortSignal, +) => boolean | Promise + +/** + * Function hook type with callback embedded. + * Session-scoped only, cannot be persisted to settings.json. + */ +export type FunctionHook = { + type: 'function' + id?: string // Optional unique ID for removal + timeout?: number + callback: FunctionHookCallback + errorMessage: string + statusMessage?: string +} + +type SessionHookMatcher = { + matcher: string + skillRoot?: string + hooks: Array<{ + hook: HookCommand | FunctionHook + onHookSuccess?: OnHookSuccess + }> +} + +export type SessionStore = { + hooks: { + [event in HookEvent]?: SessionHookMatcher[] + } +} + +/** + * Map (not Record) so .set/.delete don't change the container's identity. + * Mutator functions mutate the Map and return prev unchanged, letting + * store.ts's Object.is(next, prev) check short-circuit and skip listener + * notification. Session hooks are ephemeral per-agent runtime callbacks, + * never reactively read (only getAppState() snapshots in the query loop). + * Same pattern as agentControllers on LocalWorkflowTaskState. + * + * This matters under high-concurrency workflows: parallel() with N + * schema-mode agents fires N addFunctionHook calls in one synchronous + * tick. With a Record + spread, each call cost O(N) to copy the growing + * map (O(N虏) total) plus fired all ~30 store listeners. With Map: .set() + * is O(1), return prev means zero listener fires. + */ +export type SessionHooksState = Map + +/** + * Add a command or prompt hook to the session. + * Session hooks are temporary, in-memory only, and cleared when session ends. + */ +export function addSessionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand, + onHookSuccess?: OnHookSuccess, + skillRoot?: string, +): void { + addHookToSession( + setAppState, + sessionId, + event, + matcher, + hook, + onHookSuccess, + skillRoot, + ) +} + +/** + * Add a function hook to the session. + * Function hooks execute TypeScript callbacks in-memory for validation. + * @returns The hook ID (for removal) + */ +export function addFunctionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + callback: FunctionHookCallback, + errorMessage: string, + options?: { + timeout?: number + id?: string + }, +): string { + const id = options?.id || `function-hook-${Date.now()}-${Math.random()}` + const hook: FunctionHook = { + type: 'function', + id, + timeout: options?.timeout || 5000, + callback, + errorMessage, + } + addHookToSession(setAppState, sessionId, event, matcher, hook) + return id +} + +/** + * Remove a function hook by ID from the session. + */ +export function removeFunctionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + hookId: string, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) + if (!store) { + return prev + } + + const eventMatchers = store.hooks[event] || [] + + // Remove the hook with matching ID from all matchers + const updatedMatchers = eventMatchers + .map(matcher => { + const updatedHooks = matcher.hooks.filter(h => { + if (h.hook.type !== 'function') return true + return h.hook.id !== hookId + }) + + return updatedHooks.length > 0 + ? { ...matcher, hooks: updatedHooks } + : null + }) + .filter((m): m is SessionHookMatcher => m !== null) + + const newHooks = + updatedMatchers.length > 0 + ? { ...store.hooks, [event]: updatedMatchers } + : Object.fromEntries( + Object.entries(store.hooks).filter(([e]) => e !== event), + ) + + prev.sessionHooks.set(sessionId, { hooks: newHooks }) + return prev + }) + + logForDebugging( + `Removed function hook ${hookId} for event ${event} in session ${sessionId}`, + ) +} + +/** + * Internal helper to add a hook to session state + */ +function addHookToSession( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand | FunctionHook, + onHookSuccess?: OnHookSuccess, + skillRoot?: string, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} } + const eventMatchers = store.hooks[event] || [] + + // Find existing matcher or create new one + const existingMatcherIndex = eventMatchers.findIndex( + m => m.matcher === matcher && m.skillRoot === skillRoot, + ) + + let updatedMatchers: SessionHookMatcher[] + if (existingMatcherIndex >= 0) { + // Add to existing matcher + updatedMatchers = [...eventMatchers] + const existingMatcher = updatedMatchers[existingMatcherIndex]! + updatedMatchers[existingMatcherIndex] = { + matcher: existingMatcher.matcher, + skillRoot: existingMatcher.skillRoot, + hooks: [...existingMatcher.hooks, { hook, onHookSuccess }], + } + } else { + // Create new matcher + updatedMatchers = [ + ...eventMatchers, + { + matcher, + skillRoot, + hooks: [{ hook, onHookSuccess }], + }, + ] + } + + const newHooks = { ...store.hooks, [event]: updatedMatchers } + + prev.sessionHooks.set(sessionId, { hooks: newHooks }) + return prev + }) + + logForDebugging( + `Added session hook for event ${event} in session ${sessionId}`, + ) +} + +/** + * Remove a specific hook from the session + * @param setAppState The function to update the app state + * @param sessionId The session ID + * @param event The hook event + * @param hook The hook command to remove + */ +export function removeSessionHook( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, + event: HookEvent, + hook: HookCommand, +): void { + setAppState(prev => { + const store = prev.sessionHooks.get(sessionId) + if (!store) { + return prev + } + + const eventMatchers = store.hooks[event] || [] + + // Remove the hook from all matchers + const updatedMatchers = eventMatchers + .map(matcher => { + const updatedHooks = matcher.hooks.filter( + h => !isHookEqual(h.hook, hook), + ) + + return updatedHooks.length > 0 + ? { ...matcher, hooks: updatedHooks } + : null + }) + .filter((m): m is SessionHookMatcher => m !== null) + + const newHooks = + updatedMatchers.length > 0 + ? { ...store.hooks, [event]: updatedMatchers } + : { ...store.hooks } + + if (updatedMatchers.length === 0) { + delete newHooks[event] + } + + prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks }) + return prev + }) + + logForDebugging( + `Removed session hook for event ${event} in session ${sessionId}`, + ) +} + +// Extended hook matcher that includes optional skillRoot for skill-scoped hooks +export type SessionDerivedHookMatcher = { + matcher: string + hooks: HookCommand[] + skillRoot?: string +} + +/** + * Convert session hook matchers to regular hook matchers + * @param sessionMatchers The session hook matchers to convert + * @returns Regular hook matchers (with optional skillRoot preserved) + */ +function convertToHookMatchers( + sessionMatchers: SessionHookMatcher[], +): SessionDerivedHookMatcher[] { + return sessionMatchers.map(sm => ({ + matcher: sm.matcher, + skillRoot: sm.skillRoot, + // Filter out function hooks - they can't be persisted to HookMatcher format + hooks: sm.hooks + .map(h => h.hook) + .filter((h): h is HookCommand => h.type !== 'function'), + })) +} + +/** + * Get all session hooks for a specific event (excluding function hooks) + * @param appState The app state + * @param sessionId The session ID + * @param event Optional event to filter by + * @returns Hook matchers for the event, or all hooks if no event specified + */ +export function getSessionHooks( + appState: AppState, + sessionId: string, + event?: HookEvent, +): Map { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return new Map() + } + + const result = new Map() + + if (event) { + const sessionMatchers = store.hooks[event] + if (sessionMatchers) { + result.set(event, convertToHookMatchers(sessionMatchers)) + } + return result + } + + for (const evt of HOOK_EVENTS) { + const sessionMatchers = store.hooks[evt] + if (sessionMatchers) { + result.set(evt, convertToHookMatchers(sessionMatchers)) + } + } + + return result +} + +type FunctionHookMatcher = { + matcher: string + hooks: FunctionHook[] +} + +/** + * Get all session function hooks for a specific event + * Function hooks are kept separate because they can't be persisted to HookMatcher format. + * @param appState The app state + * @param sessionId The session ID + * @param event Optional event to filter by + * @returns Function hook matchers for the event + */ +export function getSessionFunctionHooks( + appState: AppState, + sessionId: string, + event?: HookEvent, +): Map { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return new Map() + } + + const result = new Map() + + const extractFunctionHooks = ( + sessionMatchers: SessionHookMatcher[], + ): FunctionHookMatcher[] => { + return sessionMatchers + .map(sm => ({ + matcher: sm.matcher, + hooks: sm.hooks + .map(h => h.hook) + .filter((h): h is FunctionHook => h.type === 'function'), + })) + .filter(m => m.hooks.length > 0) + } + + if (event) { + const sessionMatchers = store.hooks[event] + if (sessionMatchers) { + const functionMatchers = extractFunctionHooks(sessionMatchers) + if (functionMatchers.length > 0) { + result.set(event, functionMatchers) + } + } + return result + } + + for (const evt of HOOK_EVENTS) { + const sessionMatchers = store.hooks[evt] + if (sessionMatchers) { + const functionMatchers = extractFunctionHooks(sessionMatchers) + if (functionMatchers.length > 0) { + result.set(evt, functionMatchers) + } + } + } + + return result +} + +/** + * Get the full hook entry (including callbacks) for a specific session hook + */ +export function getSessionHookCallback( + appState: AppState, + sessionId: string, + event: HookEvent, + matcher: string, + hook: HookCommand | FunctionHook, +): + | { + hook: HookCommand | FunctionHook + onHookSuccess?: OnHookSuccess + } + | undefined { + const store = appState.sessionHooks.get(sessionId) + if (!store) { + return undefined + } + + const eventMatchers = store.hooks[event] + if (!eventMatchers) { + return undefined + } + + // Find the hook in the matchers + for (const matcherEntry of eventMatchers) { + if (matcherEntry.matcher === matcher || matcher === '') { + const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook)) + if (hookEntry) { + return hookEntry + } + } + } + + return undefined +} + +/** + * Clear all session hooks for a specific session + * @param setAppState The function to update the app state + * @param sessionId The session ID + */ +export function clearSessionHooks( + setAppState: (updater: (prev: AppState) => AppState) => void, + sessionId: string, +): void { + setAppState(prev => { + prev.sessionHooks.delete(sessionId) + return prev + }) + + logForDebugging(`Cleared all session hooks for session ${sessionId}`) +} diff --git a/read-hooks.ps1 b/read-hooks.ps1 new file mode 100644 index 0000000000..73e8c9dabe --- /dev/null +++ b/read-hooks.ps1 @@ -0,0 +1,50 @@ +$ErrorActionPreference = 'Continue' +$outDir = "E:\Yuanban\BitFun-src\hook-dump" +New-Item -ItemType Directory -Force -Path $outDir | Out-Null + +$files = @( + "src\types\hooks.ts", + "src\utils\hooks.ts", + "src\schemas\hooks.ts", + "src\utils\hooks\hookEvents.ts", + "src\utils\hooks\hookHelpers.ts", + "src\utils\hooks\hooksConfigManager.ts", + "src\utils\hooks\sessionHooks.ts", + "src\utils\hooks\execPromptHook.ts", + "src\utils\hooks\execAgentHook.ts", + "src\utils\hooks\execHttpHook.ts", + "src\utils\hooks\AsyncHookRegistry.ts", + "src\utils\hooks\postSamplingHooks.ts", + "src\utils\hooks\hooksSettings.ts", + "src\utils\hooks\hooksConfigSnapshot.ts", + "src\utils\hooks\registerFrontmatterHooks.ts", + "src\utils\hooks\registerSkillHooks.ts", + "src\utils\hooks\apiQueryHookHelper.ts", + "src\utils\hooks\fileChangedWatcher.ts", + "src\commands\hooks\hooks.tsx", + "src\services\tools\toolHooks.ts", + "src\hooks\useDeferredHookMessages.ts", + "src\query\stopHooks.ts", + "src\query\stopHooks.test.ts", + "src\costHook.ts" +) + +$base = "E:\Yuanban\cc-haha-src" + +foreach ($f in $files) { + $full = Join-Path $base $f + if (Test-Path $full) { + $lines = @(Get-Content $full) + $outName = $f -replace '[\\/]', '_' + $outPath = Join-Path $outDir $outName + "FILE: $full" | Out-File $outPath -Encoding utf8 + "LINES: $($lines.Count)" | Out-File $outPath -Append -Encoding utf8 + "==========" | Out-File $outPath -Append -Encoding utf8 + Get-Content $full | Out-File $outPath -Append -Encoding utf8 + Write-Host "Copied: $f ($($lines.Count) lines) -> $outPath" + } else { + Write-Host "NOT FOUND: $full" + } +} + +Write-Host "DONE. Files in $outDir" diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 64ab6974bf..0f96e9e603 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -35,6 +35,8 @@ use crate::agentic::tools::product_runtime::{ use crate::agentic::tools::{ resolve_tool_manifest, tool_context_runtime, ResolvedToolManifest, ToolRuntimeRestrictions, }; +use crate::agentic::tools::post_call_hooks::run_stop_hooks_for_round; +use bitfun_agent_runtime::post_call_hooks::ToolCallSummary; use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; @@ -3233,6 +3235,99 @@ impl ExecutionEngine { total_tools += round_result.tool_calls.len(); + // ── Stop hook: B01 提示蜂 + C01 审查蜂 ── + { + let tool_calls: Vec = round_result + .tool_calls + .iter() + .map(|tc| ToolCallSummary { + tool_name: tc.tool_name.clone(), + is_error: tc.is_error, + }) + .collect(); + + let assistant_text = match &round_result.assistant_message.content { + MessageContent::Text(t) => t.as_str(), + MessageContent::Multimodal { text, .. } => text.as_str(), + MessageContent::Mixed { text, .. } => text.as_str(), + MessageContent::ToolResult { .. } => "", + }; + + // Collect file reads/edits from the FILE_READ_TRACKER + // For now, extract from round_result tool calls + let file_reads: Vec = round_result + .tool_calls + .iter() + .filter(|tc| matches!(tc.tool_name.as_str(), "Read" | "read_file")) + .filter_map(|tc| { + tc.arguments + .get("file_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + let file_edits: Vec = round_result + .tool_calls + .iter() + .filter(|tc| { + matches!( + tc.tool_name.as_str(), + "Edit" | "Write" | "edit_file" | "write_file" + ) + }) + .filter_map(|tc| { + tc.arguments + .get("file_path") + .or_else(|| tc.arguments.get("path")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) + .collect(); + + let aggregated = run_stop_hooks_for_round( + &context.session_id, + &context.dialog_turn_id, + round_index as u32, + tool_calls, + assistant_text, + file_reads, + file_edits, + round_result.has_more_rounds, + ); + + // If C01 detected a violation, inject the abort message + if aggregated.is_abort() { + if let Some(abort) = &aggregated.abort { + match abort { + bitfun_agent_runtime::post_call_hooks::HookResult::Abort { + reason, + fix_instruction, + .. + } => { + let guard_message = format!( + "[ToolGuard Intercepted] {reason}\n\n{fix_instruction}" + ); + let msg = + Message::system(render_system_reminder(&guard_message)); + messages.push(msg); + warn!( + "[Stop Hook] Round {}: Abort — {}", + round_index, reason + ); + } + _ => {} + } + } + } + + // Inject B01 additional contexts + for ctx_msg in &aggregated.additional_contexts { + let msg = Message::system(render_system_reminder(ctx_msg)); + messages.push(msg); + } + } + // Track partial recovery reason from the last round if round_result.partial_recovery_reason.is_some() { last_partial_recovery_reason = round_result.partial_recovery_reason.clone(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 54fe5d9b46..b3c3f126bc 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -632,7 +632,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index e6d65c67b5..7538e723a6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -728,7 +728,7 @@ mod tests { session_id: None, dialog_turn_id: None, workspace: None, - unlocked_collapsed_tools: Vec::new(), + loaded_deferred_tool_specs: Vec::new(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::default(), custom_data: HashMap::new(), computer_use_host: None, diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index 0671052ea2..9d60b1c886 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -3,16 +3,33 @@ //! The tool framework stays generic and calls this module after successful //! tool execution. Domain-specific hooks must keep their own gating inside the //! owning domain module. +//! +//! ## Hook Architecture (inspired by cc-haha / Claude Code hook system) +//! +//! cc-haha has 24 hook events + pre-tool / post-tool + exit-code protocol. +//! BitFun's current hook is post-call only, so we use session-level state +//! tracking to enforce cross-call invariants: +//! +//! - FILE_READ_TRACKER: records files read per session → Edit/Write/Delete +//! without prior Read yields Abort. +//! - STALE_TRACKER: repeated same-tool calls → Abort at threshold 3. +//! - LIONHEART_PATH_GUARD: Delete on LionHeart library paths → Abort. +//! +//! Future: pre-tool hooks (cc-haha `executePreToolHooks`) would let us block +//! before execution instead of aborting after the fact. use crate::agentic::deep_review::tool_measurement; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, HookResult, SuccessfulToolPostCallHookExecutor, + run_stop_hooks, run_successful_tool_post_call_hooks, HookResult, StopHookAggregatedResult, + StopHookContext, StopHookExecutor, SuccessfulToolPostCallHookExecutor, ToolCallSummary, }; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Mutex; +// ── Guard 1: Stale Strategy Detection ────────────────────────── + /// Tracks consecutive same-tool calls per session for stale-strategy detection. static STALE_TRACKER: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); @@ -23,11 +40,10 @@ struct StaleToolState { consecutive_count: u32, } +/// Max consecutive same-tool calls before abort. +const STALE_STRATEGY_THRESHOLD: u32 = 3; + /// Remove the stale-tracking entry for a given session. -/// -/// Callers (e.g. session lifecycle hooks) should invoke this when a session -/// is completed, deleted, or cancelled so that the global tracker does not -/// grow without bound. #[allow(dead_code)] pub(crate) fn remove_stale_tracker_for_session(session_id: &str) { if let Ok(mut tracker) = STALE_TRACKER.lock() { @@ -35,8 +51,76 @@ pub(crate) fn remove_stale_tracker_for_session(session_id: &str) { } } -/// Max consecutive same-tool calls before abort. -const STALE_STRATEGY_THRESHOLD: u32 = 3; +// ── Guard 2: Read-before-Edit Enforcement ─────────────────────── + +/// Tracks which files have been read per session. +/// +/// When a Read call succeeds, the file path is recorded here. +/// When an Edit/Write/Delete call fires, we check whether the target +/// file was previously read in this session. If not → Abort. +static FILE_READ_TRACKER: std::sync::LazyLock>>> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Normalize a file path for consistent lookup in FILE_READ_TRACKER. +fn normalize_path(path: &str) -> String { + path.replace('\\', "/").trim_end_matches('/').to_lowercase() +} + +/// Record that a file was successfully read in this session. +fn record_file_read(session_id: &str, file_path: &str) { + if let Ok(mut tracker) = FILE_READ_TRACKER.lock() { + tracker + .entry(session_id.to_string()) + .or_default() + .insert(normalize_path(file_path)); + } +} + +/// Check whether a file was previously read in this session. +fn was_file_read(session_id: &str, file_path: &str) -> bool { + FILE_READ_TRACKER + .lock() + .ok() + .and_then(|tracker| { + tracker + .get(session_id) + .map(|files| files.contains(&normalize_path(file_path))) + }) + .unwrap_or(false) +} + +/// Remove the file-read tracking entry for a given session. +#[allow(dead_code)] +pub(crate) fn remove_file_read_tracker_for_session(session_id: &str) { + if let Ok(mut tracker) = FILE_READ_TRACKER.lock() { + tracker.remove(session_id); + } +} + +// ── Guard 3: LionHeart Path Protection ────────────────────────── + +/// Paths that must never be deleted or modified by AI agents. +const PROTECTED_PATH_PREFIXES: &[&str] = &["e:/lionheart library", "e:\\lionheart library"]; + +fn is_protected_path(file_path: &str) -> bool { + let normalized = normalize_path(file_path); + PROTECTED_PATH_PREFIXES + .iter() + .any(|prefix| normalized.starts_with(prefix)) +} + +// ── Guard 4: Unified Session Cleanup ──────────────────────────── + +/// Remove all per-session tracking state for a given session. +/// +/// Call this from session lifecycle hooks (completion, deletion, cancellation). +#[allow(dead_code)] +pub(crate) fn remove_all_trackers_for_session(session_id: &str) { + remove_stale_tracker_for_session(session_id); + remove_file_read_tracker_for_session(session_id); +} + +// ── Hook Executor ─────────────────────────────────────────────── struct CorePostCallHookExecutor; @@ -56,37 +140,45 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec input: &Value, context: &ToolUseContext, ) -> HookResult { - // 1. Stale strategy: detect repeated same-tool calls (dead-loop pattern). - if let Some(session_id) = &context.session_id { - if let Ok(mut tracker) = STALE_TRACKER.lock() { - let entry = tracker - .entry(session_id.clone()) - .or_insert_with(StaleToolState::default); - if entry.last_tool == tool_name { - entry.consecutive_count += 1; - } else { - entry.last_tool = tool_name.to_string(); - entry.consecutive_count = 1; - } - if entry.consecutive_count >= STALE_STRATEGY_THRESHOLD { - return HookResult::Abort { - reason: format!( - "Tool '{}' called {} consecutive times without strategy change", - tool_name, entry.consecutive_count - ), - fix_instruction: format!( - "Stop retrying {tool}. Read the previous results, identify the root failure cause, and choose a different approach. Do not call {tool} again without a changed strategy.", - tool = tool_name - ), - max_retries: 0, - }; - } + let session_id = match &context.session_id { + Some(id) => id.as_str(), + None => return HookResult::Continue, + }; + + // ── Track file reads (after successful Read) ── + if matches!(tool_name, "Read" | "read_file") { + if let Some(file_path) = input.get("file_path").and_then(Value::as_str) { + record_file_read(session_id, file_path); + } + } + + // ── Guard: Stale strategy detection ── + if let Ok(mut tracker) = STALE_TRACKER.lock() { + let entry = tracker + .entry(session_id.to_string()) + .or_insert_with(StaleToolState::default); + if entry.last_tool == tool_name { + entry.consecutive_count += 1; + } else { + entry.last_tool = tool_name.to_string(); + entry.consecutive_count = 1; + } + if entry.consecutive_count >= STALE_STRATEGY_THRESHOLD { + return HookResult::Abort { + reason: format!( + "Tool '{}' called {} consecutive times without strategy change", + tool_name, entry.consecutive_count + ), + fix_instruction: format!( + "Stop retrying {tool}. Read the previous results, identify the root failure cause, and choose a different approach. Do not call {tool} again without a changed strategy.", + tool = tool_name + ), + max_retries: 0, + }; } } - // 2. File read-before-edit: warn when editing without prior context. - // Full enforcement requires session-level file-read state tracking; - // this guard provides a Should-level prompt injection point. + // ── Guard: Read-before-Edit / Read-before-Delete enforcement ── if matches!( tool_name, "Edit" | "Write" | "Delete" | "edit_file" | "write_file" | "delete_file" @@ -96,17 +188,123 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec .or_else(|| input.get("path")) .and_then(Value::as_str) { - // Guard: editing a file without a known read in this turn - // is a behavior smell — inject a soft constraint via - // the result-for-assistant path rather than aborting. - let _ = file_path; // reserved for read-state cross-check + // Guard 3a: LionHeart path protection (Delete/Write) + if matches!(tool_name, "Delete" | "delete_file" | "Write" | "write_file") { + if is_protected_path(file_path) { + return HookResult::Abort { + reason: format!( + "Attempted to {} on protected path: {}", + tool_name, file_path + ), + fix_instruction: format!( + "E:/LionHeart library/ is the soul mother — absolute red line (Iron Rule 1). Never delete or overwrite files here. This operation is denied.", + ), + max_retries: 0, + }; + } + } + + // Guard 3b: Read-before-Edit — hard enforcement + if !was_file_read(session_id, file_path) { + return HookResult::Abort { + reason: format!( + "Tool '{}' called on '{}' without prior Read in this session", + tool_name, file_path + ), + fix_instruction: format!( + "Iron Rule: Read before you Edit. You must call Read on '{}' first to understand its current content, then Edit with exact text from the Read result. Never edit a file from memory.", + file_path + ), + max_retries: 1, + }; + } + } + } + + // ── Guard: ExecCommand basic safety ── + if matches!(tool_name, "ExecCommand" | "exec_command" | "Bash") { + if let Some(cmd) = input.get("cmd").and_then(Value::as_str) { + // Detect PowerShell + Chinese characters (known corruption pattern) + let has_chinese = cmd.contains(|c: char| c >= '\u{4e00}' && c <= '\u{9fff}'); + let uses_powershell = cmd.contains("powershell") + || cmd.contains("PowerShell") + || cmd.contains("pwsh"); + if has_chinese && uses_powershell { + return HookResult::Abort { + reason: "PowerShell with Chinese characters detected — known to corrupt encoding".to_string(), + fix_instruction: "Write the command as a standalone .js or .ps1 script file, then execute the script. Do not inline Chinese characters in PowerShell -Command.".to_string(), + max_retries: 1, + }; + } + } + } + + HookResult::Continue + } +} + +impl StopHookExecutor for CorePostCallHookExecutor { + /// B01 提示蜂 — context completeness check. + /// Checks whether the agent had sufficient context this round. + fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult { + // Round-level context check: did the agent edit files without reading them? + for edit in &ctx.file_edits { + let normalized_edit = edit.replace('\\', "/").trim_end_matches('/').to_lowercase(); + let was_read = ctx.file_reads.iter().any(|r| { + let nr = r.replace('\\', "/").trim_end_matches('/').to_lowercase(); + nr == normalized_edit + }); + if !was_read { + // Already caught by per-tool FILE_READ_TRACKER; here we do + // round-level aggregation but don't double-abort. + log::warn!( + "[B01 提示蜂] Round {}: file '{}' was edited but not read this round", + ctx.round_index, + edit + ); + } + } + HookResult::Continue + } + + /// C01 审查蜂 — iron-rule violation check at round level. + fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult { + // 1. Read-before-Edit round-level aggregation: + // If every single edit this round was unread, that's a systemic violation. + if !ctx.file_edits.is_empty() && !ctx.file_reads.is_empty() { + let all_unread = ctx.file_edits.iter().all(|edit| { + let ne = edit.replace('\\', "/").trim_end_matches('/').to_lowercase(); + !ctx.file_reads.iter().any(|r| { + let nr = r.replace('\\', "/").trim_end_matches('/').to_lowercase(); + nr == ne + }) + }); + if all_unread && ctx.file_edits.len() >= 2 { + return HookResult::Abort { + reason: format!( + "[C01 审查蜂] Round {}: {} files edited but none were read first", + ctx.round_index, + ctx.file_edits.len() + ), + fix_instruction: "Iron Rule: Read before you Edit. Read EVERY file you plan to modify BEFORE calling Edit. Do not edit from memory.".to_string(), + max_retries: 1, + }; } } - // 3. Exit code check: requires post-execution result inspection. - // Implemented at the tool pipeline level (exec_command.rs response - // handling), not in the post-call hook which fires before result - // inspection. Hook here serves as a future integration point. + // 2. Round-level tool error pattern detection: + // If ALL tool calls in the round failed, the agent is stuck. + if !ctx.tool_calls.is_empty() && ctx.tool_calls.iter().all(|tc| tc.is_error) { + return HookResult::Abort { + reason: format!( + "[C01 审查蜂] Round {}: all {} tool calls failed — agent is stuck", + ctx.round_index, + ctx.tool_calls.len() + ), + fix_instruction: "All tool calls failed this round. Stop and re-evaluate your approach. What are you trying to achieve? Is there a different way?".to_string(), + max_retries: 1, + }; + } HookResult::Continue } @@ -120,3 +318,30 @@ pub(crate) fn record_successful_tool_call( let mut executor = CorePostCallHookExecutor; run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor) } + +/// Convenience function to run B01/C01 stop hooks for a round. +/// +/// Called from the execution engine after each round completes. +pub(crate) fn run_stop_hooks_for_round( + session_id: &str, + turn_id: &str, + round_index: u32, + tool_calls: Vec, + assistant_text: &str, + file_reads: Vec, + file_edits: Vec, + round_has_more: bool, +) -> StopHookAggregatedResult { + let ctx = StopHookContext { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_index, + tool_calls, + assistant_text_summary: assistant_text.to_string(), + file_reads, + file_edits, + round_has_more, + }; + let mut executor = CorePostCallHookExecutor; + run_stop_hooks(&ctx, &mut executor) +} diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index c3efae29a4..370d35d0a5 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -1,17 +1,36 @@ -//! Portable post-call hook routing decisions. +//! Portable post-call and lifecycle hook routing decisions. +//! +//! Hooks are organized into two tiers: +//! +//! - **Per-tool hooks** (`SuccessfulToolPostCall`, `BehaviorGuard`): +//! fire after each successful tool call. Fine-grained, single-operation scope. +//! +//! - **Turn-level hooks** (`Stop`): +//! fire after each dialog round completes. Whole-round scope — can inspect +//! the cumulative effect of multiple tool calls in a single round. +//! +//! Inspired by cc-haha's Stop hook (used by `/goal` to evaluate progress +//! after every assistant turn) and the LionBuddy V10 supervisor chain +//! (Plan→Do→Check→Act with peer review after each stage). use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::path::Path; -/// Hook categories that concrete runtime integrations may execute after a -/// successful tool call. +/// Hook categories that concrete runtime integrations may execute. +/// +/// `SuccessfulToolPostCall` / `BehaviorGuard` fire per-tool. +/// `Stop` fires per-round (after the assistant message and all tool +/// results for a round are collected). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[non_exhaustive] pub enum RuntimeHookKind { SuccessfulToolPostCall, DeepReviewSharedContextToolUse, BehaviorGuard, + /// Fires after each dialog round completes (assistant message + tool results). + /// Carries round-level context including all tool calls in the round. + Stop, } pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 2] { @@ -203,11 +222,106 @@ where } } RuntimeHookKind::SuccessfulToolPostCall => {} + RuntimeHookKind::Stop => { + // Stop hooks are handled by run_stop_hooks() at the round level, + // not by the per-tool post-call dispatch. + } } } HookResult::Continue } +// ── Stop (round-level) hooks ──────────────────────────────────── + +/// Summary of a single tool call within a round, for Stop hook inspection. +#[derive(Debug, Clone)] +pub struct ToolCallSummary { + pub tool_name: String, + pub is_error: bool, +} + +/// Context passed to Stop hooks after each dialog round completes. +#[derive(Debug, Clone)] +pub struct StopHookContext { + pub session_id: String, + pub turn_id: String, + pub round_index: u32, + pub tool_calls: Vec, + pub assistant_text_summary: String, + pub file_reads: Vec, + pub file_edits: Vec, + pub round_has_more: bool, +} + +/// Executor for Stop (round-level) hooks. +/// +/// Implementations provide two handlers that mirror the B01/C01 dual-bee +/// pattern from LionBuddy V10: +/// +/// - `context_guard` (B01 提示蜂): checks whether the agent had sufficient +/// context to make good decisions. May inject supplemental knowledge. +/// - `behavior_guard` (C01 审查蜂): checks whether the agent violated any +/// iron rules during the round. +pub trait StopHookExecutor { + /// B01 提示蜂 — context completeness check. + fn context_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } + + /// C01 审查蜂 — iron-rule violation check. + fn behavior_guard(&mut self, _ctx: &StopHookContext) -> HookResult { + HookResult::Continue + } +} + +/// Aggregate result from running all Stop hook handlers. +/// +/// Collects the first Abort (if any) and all additional context strings. +#[derive(Debug, Clone, Default)] +pub struct StopHookAggregatedResult { + pub abort: Option, + pub additional_contexts: Vec, +} + +impl StopHookAggregatedResult { + pub fn is_abort(&self) -> bool { + self.abort.as_ref().is_some_and(|r| r.is_abort()) + } +} + +/// Run B01 context_guard followed by C01 behavior_guard for a round. +/// +/// Returns the aggregated result. If behavior_guard returns Abort, the +/// caller should inject the abort message into the next round. +pub fn run_stop_hooks( + ctx: &StopHookContext, + executor: &mut E, +) -> StopHookAggregatedResult { + let mut aggregated = StopHookAggregatedResult::default(); + + // B01 提示蜂: context completeness check (informational, non-blocking) + match executor.context_guard(ctx) { + HookResult::Continue => {} + HookResult::Abort { + reason, + fix_instruction, + .. + } => { + aggregated.additional_contexts.push(format!( + "[B01 提示蜂] 上下文不足: {reason} — {fix_instruction}" + )); + } + } + + // C01 审查蜂: iron-rule violation check (blocking on violation) + let c01 = executor.behavior_guard(ctx); + if c01.is_abort() { + aggregated.abort = Some(c01); + } + + aggregated +} + #[derive(Debug, Clone, Copy)] pub struct DeepReviewSharedContextToolUseFacts<'a> { pub tool_name: &'a str, From efaf64bab713b9848499921418dab6bda87a564c Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 06:19:16 +0800 Subject: [PATCH 41/48] fix: add remote workspace policy for legion preset commands --- .../src/api/remote_workspace_policy.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6edf3191ca..7541637281 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -274,6 +274,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("create_directory", RemoteWorkspacePolicy::LegacyUnaudited), ("create_file", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "create_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("create_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ("create_session", RemoteWorkspacePolicy::LegacyUnaudited), ("create_subagent", RemoteWorkspacePolicy::LegacyUnaudited), @@ -301,6 +305,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("delete_directory", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_file", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "delete_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("delete_mcp_server", RemoteWorkspacePolicy::LegacyUnaudited), ("delete_miniapp", RemoteWorkspacePolicy::LegacyUnaudited), ( @@ -473,6 +481,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_latest_insights", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "get_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("get_mcp_prompt", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_mcp_remote_oauth_session", @@ -723,6 +735,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "list_directory_files", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "list_legion_presets", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "list_manageable_subagents", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1519,6 +1535,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::WorkspaceAgnostic, ), ("update_cron_job", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "update_legion_preset", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "update_custom_agent", RemoteWorkspacePolicy::LegacyUnaudited, From d335a524b5c494c5d29b6ff083bb460326e1ea20 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 06:24:51 +0800 Subject: [PATCH 42/48] ci: trigger workflow run From 75b80ac5e96da62cbd450b493188501bc5ccf33f Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 13:30:55 +0800 Subject: [PATCH 43/48] feat: bee-colony review hook + DAG MiniApp + team mode verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Core: cc-haha style stop-hook LLM review (execution_engine + post_call_hooks) Every round with tool calls spawns a background thread that calls the primary LLM to review the agent's behavior. The review covers three responsibilities: 1. 书记官 (Context Guardian): detects context compression, extracts critical decisions/progress/user corrections from before compression, and injects them as CTX: prefixed context recovery notes. 2. 纪律委员 (Behavior Auditor): checks Read-before-Edit violations, LionHeart path protection, repeated tool failures without strategy change, PowerShell with Chinese+JSON, all-tools-failed, and edit-without-verification. Returns ABORT: on violations (injected as HookResult::Abort) or WARN: on warnings. 3. 提示蜂 (Skill Advisor): matches agent activity to available BitFun skills (miniapp-dev, Pair-Programming, agent-reach, etc.) and recommends loading. Architecture: std::thread::spawn -> tokio runtime -> get_global_ai_client_factory -> send_message_stream -> push_review_result to REVIEW_BUFFER -> next round's stop hook drains buffer and injects results as additional_contexts/Abort. Matches cc-haha pattern: stop -> external process -> result injected next round. ## DAG MiniApp (builtin + floating panel) - Built-in MiniApp 'bee-colony-dag' renders 8-node vertical DAG (cmd->sec->pm->plan->exec->review->accept->opt) - Node colors: idle=gray, running=blue(pulse), done=green, failed=red - Gate nodes (review/accept) marked with red dashed border - Polls app.storage('bee-colony-state') every 500ms for state updates - BeeColonyMonitor floating panel (GitBranch button in agent scenes) ## Agent definitions - bee-reviewer: B01+C01 merged, fork full context, monitor goals/compression/violations - b01-context-agent: context guardian, silent unless context degrading - c01-audit-agent: behavior auditor, silent unless violation detected ## State push tool Python helper at %APPDATA%/bitfun/tools/bee_colony_state_push.py Usage: set , reset, or full JSON push --- .../bee-colony-visual-orchestration-plan.md | 166 +++++++++ .../src/agentic/execution/execution_engine.rs | 82 ++++- .../core/src/agentic/tools/post_call_hooks.rs | 262 +++----------- .../product-domains/src/miniapp/builtin.rs | 10 + .../builtin/assets/bee-colony-dag/index.html | 24 ++ .../builtin/assets/bee-colony-dag/meta.json | 35 ++ .../builtin/assets/bee-colony-dag/style.css | 163 +++++++++ .../builtin/assets/bee-colony-dag/ui.js | 328 ++++++++++++++++++ .../agent-runtime/src/post_call_hooks.rs | 2 + src/web-ui/src/app/layout/AppLayout.tsx | 6 + .../src/app/layout/BeeColonyMonitor.scss | 102 ++++++ .../src/app/layout/BeeColonyMonitor.tsx | 147 ++++++++ 12 files changed, 1112 insertions(+), 215 deletions(-) create mode 100644 docs/plans/bee-colony-visual-orchestration-plan.md create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js create mode 100644 src/web-ui/src/app/layout/BeeColonyMonitor.scss create mode 100644 src/web-ui/src/app/layout/BeeColonyMonitor.tsx diff --git a/docs/plans/bee-colony-visual-orchestration-plan.md b/docs/plans/bee-colony-visual-orchestration-plan.md new file mode 100644 index 0000000000..7a169a93f2 --- /dev/null +++ b/docs/plans/bee-colony-visual-orchestration-plan.md @@ -0,0 +1,166 @@ +# 蜂群架构画布 + 进度监控 + +> 2026-07-18 | MiniApp SVG DAG + Agent 推状态更新 + +--- + +## 机制 + +``` +state = { nodes: { cmd: 'idle', sec: 'idle', ..., opt: 'idle' } } + +初始化: 手动计算布局 → SVG渲染 → 全灰 (idle=#3f3f46) +执行中: Agent 每过一节点 → 写 storage.json → MiniApp poll(500ms) → render() → 节点变色 +完成: 全绿 DAG (done=#34d399) +``` + +--- + +## Phase 0: 调查 ✅ + +### 调查结论 + +| 候选通道 | 可行性 | 结论 | +|---|---|---| +| Canvas API state update | ❌ | Canvas 和 MiniApp 是两个独立系统,Canvas state 通过 `useCanvasState` + postMessage 管理,不适用于 MiniApp | +| `app.storage` 直写 | ✅ **采用** | MiniApp 通过 `app.storage.get/set` 读写 KV 存储,底层是 `//storage.json` 文件 | +| MiniApp Bridge postMessage 反向通道 | ⚠️ 可行但需改代码 | Bridge 已支持 `bitfun:event` 任意事件转发,但需 Agent 侧新增 Tauri 事件发射机制 | + +### 实际通信架构 + +``` +Agent (指挥官) + │ ExecCommand: python bee_colony_state_push.py set cmd running + ▼ +storage.json (磁盘文件) + %APPDATA%/bitfun/data/miniapps/bee-colony-dag/storage.json + │ + │ MiniApp 每 500ms 轮询 app.storage.get('bee-colony-state') + ▼ +MiniApp (iframe) + state.nodes → render() → SVG 节点变色 +``` + +### 关键文件 + +- MiniApp storage 磁盘路径: `%APPDATA%/bitfun/data/miniapps/bee-colony-dag/storage.json` +- Bridge builder: `src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs` +- Bridge 前端: `src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts` +- Storage 服务: `src/crates/services/services-integrations/src/miniapp/storage.rs` +- MiniApp 管理器: `src/crates/assembly/core/src/miniapp/manager.rs` + +--- + +## Phase 1: 静态架构 DAG 渲染 ✅ + +### 产出: `MiniApp/bee-colony-dag/` + +| 文件 | 行数 | 说明 | +|---|---|---| +| `meta.json` | 35 | 权限(node.enabled=false) + i18n | +| `index.html` | 24 | Shell: header(SVG badge) + SVG canvas + footer | +| `style.css` | 163 | 深色主题 + 4色节点状态 + running脉冲动画 + gate虚线边框 | +| `ui.js` | 328 | 核心逻辑: 布局计算 + SVG render + 500ms轮询 + 徽章更新 | + +### 8 节点(标准链顺序) + +``` +cmd(指挥官) → sec(秘书B01) → pm(产品经理) → plan(规划师) + → exec(执行者) → review(审查者·Gate) → accept(验收者·Gate) → opt(优化者) +``` + +### 渲染特性 + +- 节点: 180×52px 圆角矩形, 角色名(13px bold) + 树编号(10px dim) +- 连线: 垂直箭头, Gate边红色虚线 +- Gate 节点(review/accept): 红色虚线边框叠加 +- 4 色状态: idle=#3f3f46(灰), running=#60a5fa(蓝·脉冲动画), done=#34d399(绿), failed=#ef4444(红) +- 徽章: 待命/执行中/异常/完成 + +### 与五子棋模式对比 + +| 特性 | 五子棋 | 蜂群DAG | +|---|---|---| +| 状态源 | 本地 state 对象 (用户点击) | 外部 Agent 推送 (app.storage) | +| 渲染 | renderStones() 等 SVG DOM 操作 | 同模式,render() 重绘全部节点 | +| 持久化 | app.storage 存战绩统计 | app.storage 存节点状态 | +| 刷新触发 | 用户操作后手动调 render() | 500ms 轮询检测变化后调 render() | + +--- + +## Phase 2: 状态推送 ✅ + +### Agent 端工具 + +**`%APPDATA%/bitfun/tools/bee_colony_state_push.py`** (115 行) + +```bash +# 重置全部节点为 idle +python bee_colony_state_push.py reset + +# 设置单个节点状态 +python bee_colony_state_push.py set cmd running +python bee_colony_state_push.py set cmd done "任务完成" +python bee_colony_state_push.py set exec failed "编译错误" + +# 批量推送完整状态JSON +python bee_colony_state_push.py '{"nodes":{"cmd":{"status":"done"},"sec":{"status":"running"},...}}' +``` + +### Agent 使用方式 (ExecCommand) + +``` +# 每过一个节点,执行: +ExecCommand: python C:\Users\Administrator\AppData\Roaming\bitfun\tools\bee_colony_state_push.py set + +# 例如蜂群执行标准链时: +# 1. 指挥官决策完成 → set cmd done +# 2. 秘书检索完成 → set sec done +# 3. 产品经理定义完成 → set pm done +# ...以此类推 +``` + +### MiniApp 端轮询 (ui.js 264-287行) + +```javascript +async function pollState() { + const raw = await app.storage.get("bee-colony-state"); + const hash = simpleHash(JSON.stringify(raw)); + if (hash === state.lastHash) return; // 无变化跳过 + // 更新节点状态 + render() +} +setInterval(pollState, 500); +``` + +--- + +## Phase 3: 固定展示入口 + +### 当前状态 + +MiniApp 已创建,通过以下方式之一打开: +1. BitFun 桌面端 → MiniApp 菜单 → 从文件夹导入 `MiniApp/bee-colony-dag/` +2. 或通过 `miniapp_import_from_path` Tauri 命令导入 + +### 待实现 + +- [ ] MiniApp 固定到侧边栏/底部面板 +- [ ] 参考: `MiniAppRunner.tsx` 渲染容器, `NavPanel/MiniAppEntry.tsx` 入口 +- [ ] 方案A: 复用 FloatingMiniChat 的浮动面板模式,在底部固定显示 +- [ ] 方案B: 在 NavPanel 添加"蜂群监控"快捷入口 +- [ ] 确保不随聊天对话框滚动消失 + +--- + +## 文件清单 + +| 路径 | 用途 | +|---|---| +| `docs/plans/bee-colony-visual-orchestration-plan.md` | 本计划文档 | +| `MiniApp/bee-colony-dag/meta.json` | MiniApp 元数据 + 权限 | +| `MiniApp/bee-colony-dag/index.html` | HTML Shell | +| `MiniApp/bee-colony-dag/style.css` | 深色主题样式 | +| `MiniApp/bee-colony-dag/ui.js` | DAG 渲染 + 轮询逻辑 | +| `%APPDATA%/bitfun/tools/bee_colony_state_push.py` | Agent 端状态推送工具 | +| `%APPDATA%/bitfun/legions/bee-colony-standard.json` | 蜂群标准链模板 (8节点) | +| `%APPDATA%/bitfun/agents/*.md` | 8 角色 Agent 定义 | diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 0f96e9e603..c3adb469ab 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -52,8 +52,8 @@ use bitfun_ai_adapters::ModelExchangeTraceConfig; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; -use std::path::Path; use std::sync::Arc; +use std::path::Path; use tokio_util::sync::CancellationToken; use tool_runtime::context::PrimaryModelFacts; @@ -3240,9 +3240,19 @@ impl ExecutionEngine { let tool_calls: Vec = round_result .tool_calls .iter() - .map(|tc| ToolCallSummary { - tool_name: tc.tool_name.clone(), - is_error: tc.is_error, + .map(|tc| { + let preview = serde_json::to_string(&tc.arguments) + .unwrap_or_default(); + let preview = if preview.len() > 256 { + format!("{}...", &preview[..253]) + } else { + preview + }; + ToolCallSummary { + tool_name: tc.tool_name.clone(), + is_error: tc.is_error, + input_preview: preview, + } }) .collect(); @@ -3286,13 +3296,14 @@ impl ExecutionEngine { .collect(); let aggregated = run_stop_hooks_for_round( + &self.session_manager, &context.session_id, &context.dialog_turn_id, round_index as u32, - tool_calls, + tool_calls.clone(), assistant_text, - file_reads, - file_edits, + file_reads.clone(), + file_edits.clone(), round_result.has_more_rounds, ); @@ -3326,6 +3337,63 @@ impl ExecutionEngine { let msg = Message::system(render_system_reminder(ctx_msg)); messages.push(msg); } + + // ── 蜂群审查(cc-haha模式:stop hook → fast LLM → 注入结果)── + if !tool_calls.is_empty() { + let tl: Vec = tool_calls.iter() + .map(|tc| format!("{}{}", tc.tool_name, if tc.is_error { "(FAIL)" } else { "" })) + .collect(); + let ts: String = assistant_text.chars().take(400).collect(); + let fr2: Vec = file_reads.iter().take(5).cloned().collect(); + let fe2: Vec = file_edits.iter().take(5).cloned().collect(); + + let review_prompt = format!( + "你是蜂群审查员(书记官+纪律委员+提示蜂)。审查Agent本轮:\n\ + 工具:[{}] 读取:[{}] 编辑:[{}] 动作:{}\n\n\ + ## 书记官(上下文守护)\n\ + - 上下文压缩了?提取压缩前的关键决策/进度/用户纠正 → CTX:<要点>\n\ + - 轮次>20或token告急?预摘要关键状态 → CTX:<摘要>\n\ + - Agent忘记重要信息(重复提问/忽略决策)?→ CTX:<提醒>\n\n\ + ## 纪律委员(行为审查)\n\ + - 编辑前未Read?→ ABORT:未读即改-<文件>\n\ + - 碰了LionHeart库?→ ABORT:受保护路径\n\ + - 同工具连续失败?→ ABORT:策略死循环\n\ + - PS+中文+JSON?→ ABORT:PS编码风险\n\ + - 全部工具失败?→ ABORT:全部失败\n\ + - 改完不验证?→ WARN:未验证\n\n\ + ## 提示蜂(技能推荐)\n\ + - MiniApp相关但没加载miniapp-dev?→ SKILL:miniapp-dev\n\ + - 编码但没加载Pair-Programming?→ SKILL:Pair Programming\n\ + - 调研但没加载agent-reach?→ SKILL:agent-reach\n\n\ + 输出(每行一个):PASS / ABORT:<原因> / WARN:<问题> / CTX:<内容> / SKILL:<名称>\n\ + 一切正常只回复PASS。", + tl.join(","), fr2.join(","), fe2.join(","), ts + ); + + let sid2 = context.session_id.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().expect("bee-review"); + rt.block_on(async move { + if let Ok(factory) = crate::infrastructure::ai::get_global_ai_client_factory().await { + if let Ok(client) = factory.get_client_resolved("primary").await { + let msgs = vec![bitfun_core_types::Message::user(review_prompt)]; + if let Ok(mut stream) = client.send_message_stream(msgs, None, None).await { + use futures::StreamExt; + let mut result = String::new(); + while let Some(Ok(chunk)) = stream.stream.next().await { + if let Some(t) = chunk.text { result.push_str(&t); } + } + let trimmed = result.trim().to_string(); + if !trimmed.is_empty() && trimmed != "PASS" { + crate::agentic::tools::post_call_hooks::push_review_result(&sid2, trimmed); + } + } + } + } + }); + }); + } + } // Track partial recovery reason from the last round diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index 9d60b1c886..89d906b5b0 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -19,6 +19,7 @@ //! before execution instead of aborting after the fact. use crate::agentic::deep_review::tool_measurement; +use crate::agentic::session::session_manager::SessionManager; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use bitfun_agent_runtime::post_call_hooks::{ run_stop_hooks, run_successful_tool_post_call_hooks, HookResult, StopHookAggregatedResult, @@ -26,32 +27,9 @@ use bitfun_agent_runtime::post_call_hooks::{ }; use serde_json::Value; use std::collections::{HashMap, HashSet}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; -// ── Guard 1: Stale Strategy Detection ────────────────────────── - -/// Tracks consecutive same-tool calls per session for stale-strategy detection. -static STALE_TRACKER: std::sync::LazyLock>> = - std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); - -#[derive(Debug, Clone, Default)] -struct StaleToolState { - last_tool: String, - consecutive_count: u32, -} - -/// Max consecutive same-tool calls before abort. -const STALE_STRATEGY_THRESHOLD: u32 = 3; - -/// Remove the stale-tracking entry for a given session. -#[allow(dead_code)] -pub(crate) fn remove_stale_tracker_for_session(session_id: &str) { - if let Ok(mut tracker) = STALE_TRACKER.lock() { - tracker.remove(session_id); - } -} - -// ── Guard 2: Read-before-Edit Enforcement ─────────────────────── +// ── File Read Tracking (data collection for B01/C01 context) ──── /// Tracks which files have been read per session. /// @@ -76,19 +54,6 @@ fn record_file_read(session_id: &str, file_path: &str) { } } -/// Check whether a file was previously read in this session. -fn was_file_read(session_id: &str, file_path: &str) -> bool { - FILE_READ_TRACKER - .lock() - .ok() - .and_then(|tracker| { - tracker - .get(session_id) - .map(|files| files.contains(&normalize_path(file_path))) - }) - .unwrap_or(false) -} - /// Remove the file-read tracking entry for a given session. #[allow(dead_code)] pub(crate) fn remove_file_read_tracker_for_session(session_id: &str) { @@ -97,27 +62,17 @@ pub(crate) fn remove_file_read_tracker_for_session(session_id: &str) { } } -// ── Guard 3: LionHeart Path Protection ────────────────────────── +// ── Bee-Review Buffer (cc-haha pattern: LLM result → inject next round) ── -/// Paths that must never be deleted or modified by AI agents. -const PROTECTED_PATH_PREFIXES: &[&str] = &["e:/lionheart library", "e:\\lionheart library"]; +use std::sync::LazyLock; +static REVIEW_BUFFER: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); -fn is_protected_path(file_path: &str) -> bool { - let normalized = normalize_path(file_path); - PROTECTED_PATH_PREFIXES - .iter() - .any(|prefix| normalized.starts_with(prefix)) -} - -// ── Guard 4: Unified Session Cleanup ──────────────────────────── - -/// Remove all per-session tracking state for a given session. -/// -/// Call this from session lifecycle hooks (completion, deletion, cancellation). -#[allow(dead_code)] -pub(crate) fn remove_all_trackers_for_session(session_id: &str) { - remove_stale_tracker_for_session(session_id); - remove_file_read_tracker_for_session(session_id); +/// Push a review result for a session (called from execution_engine spawn). +pub(crate) fn push_review_result(session_id: &str, result: String) { + if let Ok(mut buf) = REVIEW_BUFFER.lock() { + buf.entry(session_id.to_string()).or_default().push(result); + } } // ── Hook Executor ─────────────────────────────────────────────── @@ -145,167 +100,27 @@ impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExec None => return HookResult::Continue, }; - // ── Track file reads (after successful Read) ── + // ── Track file reads (data collection only, no enforcement) ── if matches!(tool_name, "Read" | "read_file") { if let Some(file_path) = input.get("file_path").and_then(Value::as_str) { record_file_read(session_id, file_path); } } - // ── Guard: Stale strategy detection ── - if let Ok(mut tracker) = STALE_TRACKER.lock() { - let entry = tracker - .entry(session_id.to_string()) - .or_insert_with(StaleToolState::default); - if entry.last_tool == tool_name { - entry.consecutive_count += 1; - } else { - entry.last_tool = tool_name.to_string(); - entry.consecutive_count = 1; - } - if entry.consecutive_count >= STALE_STRATEGY_THRESHOLD { - return HookResult::Abort { - reason: format!( - "Tool '{}' called {} consecutive times without strategy change", - tool_name, entry.consecutive_count - ), - fix_instruction: format!( - "Stop retrying {tool}. Read the previous results, identify the root failure cause, and choose a different approach. Do not call {tool} again without a changed strategy.", - tool = tool_name - ), - max_retries: 0, - }; - } - } - - // ── Guard: Read-before-Edit / Read-before-Delete enforcement ── - if matches!( - tool_name, - "Edit" | "Write" | "Delete" | "edit_file" | "write_file" | "delete_file" - ) { - if let Some(file_path) = input - .get("file_path") - .or_else(|| input.get("path")) - .and_then(Value::as_str) - { - // Guard 3a: LionHeart path protection (Delete/Write) - if matches!(tool_name, "Delete" | "delete_file" | "Write" | "write_file") { - if is_protected_path(file_path) { - return HookResult::Abort { - reason: format!( - "Attempted to {} on protected path: {}", - tool_name, file_path - ), - fix_instruction: format!( - "E:/LionHeart library/ is the soul mother — absolute red line (Iron Rule 1). Never delete or overwrite files here. This operation is denied.", - ), - max_retries: 0, - }; - } - } - - // Guard 3b: Read-before-Edit — hard enforcement - if !was_file_read(session_id, file_path) { - return HookResult::Abort { - reason: format!( - "Tool '{}' called on '{}' without prior Read in this session", - tool_name, file_path - ), - fix_instruction: format!( - "Iron Rule: Read before you Edit. You must call Read on '{}' first to understand its current content, then Edit with exact text from the Read result. Never edit a file from memory.", - file_path - ), - max_retries: 1, - }; - } - } - } - - // ── Guard: ExecCommand basic safety ── - if matches!(tool_name, "ExecCommand" | "exec_command" | "Bash") { - if let Some(cmd) = input.get("cmd").and_then(Value::as_str) { - // Detect PowerShell + Chinese characters (known corruption pattern) - let has_chinese = cmd.contains(|c: char| c >= '\u{4e00}' && c <= '\u{9fff}'); - let uses_powershell = cmd.contains("powershell") - || cmd.contains("PowerShell") - || cmd.contains("pwsh"); - if has_chinese && uses_powershell { - return HookResult::Abort { - reason: "PowerShell with Chinese characters detected — known to corrupt encoding".to_string(), - fix_instruction: "Write the command as a standalone .js or .ps1 script file, then execute the script. Do not inline Chinese characters in PowerShell -Command.".to_string(), - max_retries: 1, - }; - } - } - } - + // All enforcement moved to B01+C01 async agent review. HookResult::Continue } } impl StopHookExecutor for CorePostCallHookExecutor { - /// B01 提示蜂 — context completeness check. - /// Checks whether the agent had sufficient context this round. - fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult { - // Round-level context check: did the agent edit files without reading them? - for edit in &ctx.file_edits { - let normalized_edit = edit.replace('\\', "/").trim_end_matches('/').to_lowercase(); - let was_read = ctx.file_reads.iter().any(|r| { - let nr = r.replace('\\', "/").trim_end_matches('/').to_lowercase(); - nr == normalized_edit - }); - if !was_read { - // Already caught by per-tool FILE_READ_TRACKER; here we do - // round-level aggregation but don't double-abort. - log::warn!( - "[B01 提示蜂] Round {}: file '{}' was edited but not read this round", - ctx.round_index, - edit - ); - } - } + /// B01+C01 review is handled by async agent sessions spawned in + /// execution_engine via std::thread + coordinator.start_dialog_turn. + /// The stop hook is a pure trigger point — no synchronous checks. + fn context_guard(&mut self, _ctx: &StopHookContext) -> HookResult { HookResult::Continue } - /// C01 审查蜂 — iron-rule violation check at round level. - fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult { - // 1. Read-before-Edit round-level aggregation: - // If every single edit this round was unread, that's a systemic violation. - if !ctx.file_edits.is_empty() && !ctx.file_reads.is_empty() { - let all_unread = ctx.file_edits.iter().all(|edit| { - let ne = edit.replace('\\', "/").trim_end_matches('/').to_lowercase(); - !ctx.file_reads.iter().any(|r| { - let nr = r.replace('\\', "/").trim_end_matches('/').to_lowercase(); - nr == ne - }) - }); - if all_unread && ctx.file_edits.len() >= 2 { - return HookResult::Abort { - reason: format!( - "[C01 审查蜂] Round {}: {} files edited but none were read first", - ctx.round_index, - ctx.file_edits.len() - ), - fix_instruction: "Iron Rule: Read before you Edit. Read EVERY file you plan to modify BEFORE calling Edit. Do not edit from memory.".to_string(), - max_retries: 1, - }; - } - } - - // 2. Round-level tool error pattern detection: - // If ALL tool calls in the round failed, the agent is stuck. - if !ctx.tool_calls.is_empty() && ctx.tool_calls.iter().all(|tc| tc.is_error) { - return HookResult::Abort { - reason: format!( - "[C01 审查蜂] Round {}: all {} tool calls failed — agent is stuck", - ctx.round_index, - ctx.tool_calls.len() - ), - fix_instruction: "All tool calls failed this round. Stop and re-evaluate your approach. What are you trying to achieve? Is there a different way?".to_string(), - max_retries: 1, - }; - } - + fn behavior_guard(&mut self, _ctx: &StopHookContext) -> HookResult { HookResult::Continue } } @@ -319,10 +134,11 @@ pub(crate) fn record_successful_tool_call( run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor) } -/// Convenience function to run B01/C01 stop hooks for a round. -/// -/// Called from the execution engine after each round completes. +/// Stop hook — injects self-review reminder at round boundaries. +/// Matches the goal system pattern: no separate sessions, no context pushing, +/// just a reminder injected for the main agent to self-review. pub(crate) fn run_stop_hooks_for_round( + _session_manager: &Arc, session_id: &str, turn_id: &str, round_index: u32, @@ -343,5 +159,35 @@ pub(crate) fn run_stop_hooks_for_round( round_has_more, }; let mut executor = CorePostCallHookExecutor; - run_stop_hooks(&ctx, &mut executor) + let mut result = run_stop_hooks(&ctx, &mut executor); + + // ── Bee-review: drain pending LLM results (cc-haha pattern) ── + if let Ok(mut buf) = REVIEW_BUFFER.lock() { + if let Some(results) = buf.remove(ctx.session_id.as_str()) { + for r in results { + let trimmed = r.trim(); + if trimmed.is_empty() || trimmed == "PASS" { + continue; + } + if trimmed.starts_with("ABORT:") || trimmed.starts_with("ABORT:") { + result.abort = Some(HookResult::Abort { + reason: format!("[审查员] {}", trimmed), + fix_instruction: "按审查员建议修正后继续。".to_string(), + max_retries: 1, + }); + } else if trimmed.starts_with("CTX:") || trimmed.starts_with("CTX:") { + result.additional_contexts.push(format!("[书记官] 上下文恢复: {}", &trimmed[4..].trim())); + } else if trimmed.starts_with("SKILL:") || trimmed.starts_with("SKILL:") { + result.additional_contexts.push(format!("[提示蜂] 推荐加载: {}", &trimmed[6..].trim())); + } else if trimmed.starts_with("WARN:") || trimmed.starts_with("WARN:") { + result.additional_contexts.push(format!("[审查员] 警告: {}", &trimmed[5..].trim())); + } else { + result.additional_contexts.push(format!("[审查员] {}", trimmed)); + } + } + } + } + + result } + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 24102cf780..1808a59c54 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -159,6 +159,16 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ worker_js: include_str!("builtin/assets/ppt-live/worker.js"), esm_dependencies_json: include_str!("builtin/assets/ppt-live/esm_dependencies.json"), }, + BuiltinMiniAppBundle { + id: "builtin-bee-colony-dag", + version: 1, + meta_json: include_str!("builtin/assets/bee-colony-dag/meta.json"), + html: include_str!("builtin/assets/bee-colony-dag/index.html"), + css: include_str!("builtin/assets/bee-colony-dag/style.css"), + ui_js: include_str!("builtin/assets/bee-colony-dag/ui.js"), + worker_js: "", + esm_dependencies_json: "[]", + }, ]; pub fn builtin_content_hash(app: &BuiltinMiniAppBundle) -> String { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html new file mode 100644 index 0000000000..ce4ec25fff --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/index.html @@ -0,0 +1,24 @@ + + + + + + 蜂群架构监控 + + + +
+
+

蜂群架构监控

+ 待命 +
+
+ +
+
+ 轮询中... +
+
+ + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json new file mode 100644 index 0000000000..885f23d0c8 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/meta.json @@ -0,0 +1,35 @@ +{ + "id": "builtin-bee-colony-dag", + "name": "蜂群架构监控", + "description": "蜂群八角色 DAG 架构图,实时监控各节点执行状态。Agent 推送状态更新,节点颜色实时变化。", + "icon": "GitBranch", + "category": "tool", + "tags": ["蜂群", "DAG", "监控", "Agent"], + "version": 1, + "created_at": 0, + "updated_at": 0, + "permissions": { + "fs": { + "read": ["{appdata}"], + "write": ["{appdata}"] + }, + "shell": { "allow": [] }, + "net": { "allow": [] }, + "node": { "enabled": false } + }, + "ai_context": null, + "i18n": { + "locales": { + "zh-CN": { + "name": "蜂群架构监控", + "description": "蜂群八角色 DAG 架构图,实时监控各节点执行状态。", + "tags": ["蜂群", "DAG", "监控", "Agent"] + }, + "en-US": { + "name": "Bee Colony Monitor", + "description": "Bee Colony 8-role DAG architecture with real-time node status monitoring.", + "tags": ["bee-colony", "DAG", "monitor", "Agent"] + } + } + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css new file mode 100644 index 0000000000..1416becce9 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/style.css @@ -0,0 +1,163 @@ +*, *::before, *::after { box-sizing: border-box; } +body, html { margin: 0; padding: 0; height: 100%; overflow: hidden; } + +:root { + /* Surfaces */ + --dag-bg: var(--bitfun-bg, #121214); + --dag-surface: var(--bitfun-bg-secondary, #1c1c1f); + --dag-surface-2: var(--bitfun-element-bg, rgba(255,255,255,0.06)); + --dag-border: var(--bitfun-border-subtle, rgba(255,255,255,0.10)); + + /* Text */ + --dag-text: var(--bitfun-text, #e8e8e8); + --dag-text-soft: var(--bitfun-text-secondary, #b0b0b0); + --dag-text-mute: var(--bitfun-text-muted, #858585); + + /* Semantic */ + --dag-accent: var(--bitfun-accent, #60a5fa); + --dag-success: var(--bitfun-success, #34d399); + --dag-error: var(--bitfun-error, #ef4444); + + /* Node status */ + --dag-idle: #3f3f46; + --dag-running: #60a5fa; + --dag-done: #34d399; + --dag-failed: #ef4444; + + --dag-radius: var(--bitfun-radius, 8px); + --dag-font: var(--bitfun-font-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif); +} + +/* ---- Layout ---- */ +.bee-dag { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--dag-bg); + color: var(--dag-text); + font-family: var(--dag-font); + font-size: 13px; + user-select: none; +} + +/* ---- Top bar ---- */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--dag-surface); + border-bottom: 1px solid var(--dag-border); + flex-shrink: 0; +} + +.topbar__title { + margin: 0; + font-size: 15px; + font-weight: 600; + color: var(--dag-text); + letter-spacing: 0.02em; +} + +.topbar__badge { + display: inline-flex; + align-items: center; + padding: 3px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 500; + background: var(--dag-surface-2); + color: var(--dag-text-soft); + transition: background 0.3s, color 0.3s; +} + +.topbar__badge--running { + background: rgba(96,165,250,0.16); + color: var(--dag-running); +} + +.topbar__badge--done { + background: rgba(52,211,153,0.14); + color: var(--dag-done); +} + +.topbar__badge--failed { + background: rgba(239,68,68,0.14); + color: var(--dag-error); +} + +/* ---- Canvas ---- */ +.canvas-wrap { + flex: 1; + overflow: hidden; + position: relative; +} + +.dag-svg { + width: 100%; + height: 100%; + display: block; +} + +/* ---- Bottom bar ---- */ +.bottombar { + display: flex; + align-items: center; + justify-content: center; + padding: 6px 16px; + background: var(--dag-surface); + border-top: 1px solid var(--dag-border); + flex-shrink: 0; +} + +.bottombar__hint { + font-size: 11px; + color: var(--dag-text-mute); + letter-spacing: 0.03em; +} + +/* ---- SVG node animations ---- */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} + +.node--running .dag-node-rect { + animation: pulse 1.2s ease-in-out infinite; +} + +/* ---- SVG elements ---- */ +.dag-edge { + stroke: rgba(255,255,255,0.16); + stroke-width: 1.6; +} + +.dag-edge--gate { + stroke: rgba(239,68,68,0.40); + stroke-dasharray: 6 3; +} + +.dag-node-rect { + transition: fill 0.35s ease; +} + +.dag-node-text { + fill: var(--dag-text); + font-family: var(--dag-font); + font-size: 13px; + font-weight: 600; +} + +.dag-node-sub { + fill: var(--dag-text-mute); + font-family: var(--dag-font); + font-size: 10px; + font-weight: 400; +} + +.dag-gate-dash { + fill: none; + stroke: rgba(239,68,68,0.45); + stroke-width: 1.8; + stroke-dasharray: 5 3; +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js new file mode 100644 index 0000000000..3d7b0d4d68 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/bee-colony-dag/ui.js @@ -0,0 +1,328 @@ +// Bee Colony DAG Monitor — main logic +// Renders an 8-role vertical DAG with real-time status polling via app.storage. +// Vanilla JS, no frameworks. SVG rendered via createElementNS. + +const SVG_NS = "http://www.w3.org/2000/svg"; + +// ── Role definitions ────────────────────────────────────────── +const ROLE_DEFS = [ + { id: "cmd", role: "\u6307\u6325\u5b98", tree: "\u6811 1-3 P\u2192C\u2192D", gate: false }, + { id: "sec", role: "\u79d8\u4e66 B01", tree: "L3 \u5e93\u68c0\u7d22", gate: false }, + { id: "pm", role: "\u4ea7\u54c1\u7ecf\u7406", tree: "R-ID \u9700\u6c42\u5b9a\u4e49", gate: false }, + { id: "plan", role: "\u89c4\u5212\u5e08", tree: "\u6811 4-6 P\u2192D\u2192C", gate: false }, + { id: "exec", role: "\u6267\u884c\u8005", tree: "\u6811 7-10 O\u2192O\u2192D\u2192A", gate: false }, + { id: "review", role: "\u5ba1\u67e5\u8005", tree: "\u529f\u80fd\u00b7\u5b89\u5168\u00b7Debug", gate: true }, + { id: "accept", role: "\u9a8c\u6536\u8005", tree: "R-ID \u9010\u9879\u95ed\u5408", gate: true }, + { id: "opt", role: "\u4f18\u5316\u8005", tree: "\u590d\u76d8\u00b7\u5f52\u6863\u00b7\u77e5\u8bc6\u5e93", gate: false }, +]; + +// ── Layout constants ────────────────────────────────────────── +const NODE_W = 180; +const NODE_H = 52; +const NODE_RX = 8; +const V_GAP = 88; // center-to-center vertical spacing +const PAD_X = 60; +const PAD_TOP = 30; +const PAD_BOT = 30; + +// ── State ────────────────────────────────────────────────────── +const state = { + layout: null, // { viewW, viewH, positions: [{x,y}] } + nodes: {}, // { cmd: {status, detail}, ... } + lastHash: "", +}; + +// ── Simple hash ──────────────────────────────────────────────── +function simpleHash(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const c = str.charCodeAt(i); + hash = ((hash << 5) - hash) + c; + hash |= 0; + } + return String(hash); +} + +// ── Compute layout ───────────────────────────────────────────── +function computeLayout(totalW, totalH) { + const cx = totalW / 2; + const positions = ROLE_DEFS.map((_, i) => ({ + x: cx - NODE_W / 2, + y: PAD_TOP + i * V_GAP, + })); + + const viewH = PAD_TOP + ROLE_DEFS.length * V_GAP + PAD_BOT; + + return { + viewW: totalW, + viewH: viewH, + positions: positions, + }; +} + +// ── Status helpers ───────────────────────────────────────────── +function getStatus(nodeId) { + const n = state.nodes[nodeId]; + return n && n.status ? n.status : "idle"; +} + +function getFillColor(status) { + switch (status) { + case "running": return "var(--dag-running)"; + case "done": return "var(--dag-done)"; + case "failed": return "var(--dag-failed)"; + default: return "var(--dag-idle)"; + } +} + +// ── Render ───────────────────────────────────────────────────── +function render() { + const svg = document.getElementById("dag-svg"); + if (!svg) return; + + // Measure container + const wrap = svg.parentElement; + const w = wrap.clientWidth || 800; + const h = wrap.clientHeight || 600; + + const layout = computeLayout(w, h); + state.layout = layout; + + svg.setAttribute("viewBox", "0 0 " + layout.viewW + " " + layout.viewH); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + + // Clear + while (svg.firstChild) svg.removeChild(svg.firstChild); + + // Defs: arrow marker + const defs = createSvgEl("defs"); + const marker = createSvgEl("marker", { + id: "arrow", + viewBox: "0 0 10 10", + refX: "5", + refY: "10", + markerWidth: "6", + markerHeight: "6", + orient: "auto", + }); + const arrowPath = createSvgEl("path", { + d: "M 0 0 L 5 10 L 10 0", + fill: "rgba(255,255,255,0.16)", + }); + marker.appendChild(arrowPath); + defs.appendChild(marker); + + // Gate edge marker + const markerGate = createSvgEl("marker", { + id: "arrow-gate", + viewBox: "0 0 10 10", + refX: "5", + refY: "10", + markerWidth: "6", + markerHeight: "6", + orient: "auto", + }); + const arrowPathGate = createSvgEl("path", { + d: "M 0 0 L 5 10 L 10 0", + fill: "rgba(239,68,68,0.40)", + }); + markerGate.appendChild(arrowPathGate); + defs.appendChild(markerGate); + svg.appendChild(defs); + + // ── Edges (behind nodes) ── + for (let i = 0; i < ROLE_DEFS.length - 1; i++) { + const src = layout.positions[i]; + const dst = layout.positions[i + 1]; + const isGate = ROLE_DEFS[i + 1].gate || ROLE_DEFS[i].gate; + + const x1 = src.x + NODE_W / 2; + const y1 = src.y + NODE_H; + const x2 = dst.x + NODE_W / 2; + const y2 = dst.y; + + const line = createSvgEl("line", { + x1: x1, y1: y1, + x2: x2, y2: y2, + class: "dag-edge" + (isGate ? " dag-edge--gate" : ""), + "marker-end": isGate ? "url(#arrow-gate)" : "url(#arrow)", + }); + svg.appendChild(line); + } + + // ── Nodes ── + for (let i = 0; i < ROLE_DEFS.length; i++) { + const def = ROLE_DEFS[i]; + const pos = layout.positions[i]; + const status = getStatus(def.id); + + const g = createSvgEl("g", { + class: "dag-node" + (status === "running" ? " node--running" : ""), + }); + + // Node rect + const rect = createSvgEl("rect", { + x: pos.x, + y: pos.y, + width: NODE_W, + height: NODE_H, + rx: NODE_RX, + ry: NODE_RX, + class: "dag-node-rect", + fill: getFillColor(status), + stroke: "rgba(255,255,255,0.08)", + "stroke-width": "1", + }); + g.appendChild(rect); + + // Gate dashed overlay + if (def.gate) { + const dash = createSvgEl("rect", { + x: pos.x + 1.5, + y: pos.y + 1.5, + width: NODE_W - 3, + height: NODE_H - 3, + rx: NODE_RX - 1, + ry: NODE_RX - 1, + class: "dag-gate-dash", + }); + g.appendChild(dash); + } + + // Role name + const textRole = createSvgEl("text", { + x: pos.x + NODE_W / 2, + y: pos.y + 21, + "text-anchor": "middle", + class: "dag-node-text", + }); + textRole.textContent = def.role; + g.appendChild(textRole); + + // Tree number + const textTree = createSvgEl("text", { + x: pos.x + NODE_W / 2, + y: pos.y + 38, + "text-anchor": "middle", + class: "dag-node-sub", + }); + textTree.textContent = def.tree; + g.appendChild(textTree); + + svg.appendChild(g); + } +} + +// ── SVG element helper ───────────────────────────────────────── +function createSvgEl(tag, attrs) { + const el = document.createElementNS(SVG_NS, tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + el.setAttribute(k, String(v)); + } + } + return el; +} + +// ── Badge update ─────────────────────────────────────────────── +function updateBadge(raw) { + const badge = document.getElementById("status-badge"); + if (!badge) return; + + const nodes = raw && raw.nodes ? raw.nodes : {}; + const vals = Object.values(nodes); + const statuses = vals.map(function (n) { return n.status || "idle"; }); + + let label = "\u5f85\u547d"; + let cls = ""; + + if (statuses.length > 0) { + const hasFailed = statuses.indexOf("failed") !== -1; + const hasRunning = statuses.indexOf("running") !== -1; + const allDone = statuses.every(function (s) { return s === "done"; }); + const allIdle = statuses.every(function (s) { return s === "idle"; }); + + if (hasFailed) { + label = "\u5f02\u5e38"; + cls = "topbar__badge--failed"; + } else if (hasRunning) { + label = "\u6267\u884c\u4e2d"; + cls = "topbar__badge--running"; + } else if (allDone) { + label = "\u5b8c\u6210"; + cls = "topbar__badge--done"; + } else if (allIdle) { + label = "\u5f85\u547d"; + cls = ""; + } + } + + badge.textContent = label; + badge.className = "topbar__badge " + cls; +} + +// ── Polling ──────────────────────────────────────────────────── +async function pollState() { + try { + const raw = await app.storage.get("bee-colony-state"); + if (!raw) return; + + const hash = simpleHash(JSON.stringify(raw)); + if (hash === state.lastHash) return; + state.lastHash = hash; + + // Update node statuses + if (raw.nodes) { + for (const [id, nodeState] of Object.entries(raw.nodes)) { + state.nodes[id] = nodeState; + } + } + + render(); + updateBadge(raw); + } catch (_e) { + // Ignore polling errors — the MiniApp may be running + // in a context where app.storage is not yet available. + } +} + +// ── Init ─────────────────────────────────────────────────────── +async function init() { + // Load any previously persisted state + try { + const saved = await app.storage.get("bee-colony-state"); + if (saved && saved.nodes) { + for (const [id, nodeState] of Object.entries(saved.nodes)) { + state.nodes[id] = nodeState; + } + } + updateBadge(saved); + } catch (_e) { + // No saved state yet + } + + // Initial render to show the DAG structure + render(); + + // Start polling + setInterval(pollState, 500); + pollState(); + + // Update poll hint + const hint = document.getElementById("poll-hint"); + if (hint) { + hint.textContent = "\u8f6e\u8be2\u4e2d (500ms)"; + } + + // Handle resize + window.addEventListener("resize", function () { + render(); + }); +} + +// ── Boot ─────────────────────────────────────────────────────── +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index 370d35d0a5..1cf230fbb6 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -238,6 +238,8 @@ where pub struct ToolCallSummary { pub tool_name: String, pub is_error: bool, + /// First 256 chars of serialized tool input, for hook inspection. + pub input_preview: String, } /// Context passed to Stop hooks after each dialog round completes. diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 81e5091126..3c953f9ff9 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -51,6 +51,9 @@ const ToolbarMode = lazy(() => const FloatingMiniChat = lazy(() => import('./FloatingMiniChat').then(module => ({ default: module.FloatingMiniChat })) ); +const BeeColonyMonitor = lazy(() => + import('./BeeColonyMonitor').then(module => ({ default: module.BeeColonyMonitor })) +); const AboutDialog = lazy(() => import('../components/AboutDialog').then(module => ({ default: module.AboutDialog })) ); @@ -748,6 +751,9 @@ const AppLayout: React.FC = ({ className = '' }) => { )} + + + {pendingAcpSessionClients.length > 0 && (
diff --git a/src/web-ui/src/app/layout/BeeColonyMonitor.scss b/src/web-ui/src/app/layout/BeeColonyMonitor.scss new file mode 100644 index 0000000000..2b1122e940 --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.scss @@ -0,0 +1,102 @@ +// BeeColonyMonitor — floating panel for bee-colony-dag MiniApp +$panel-width: 380px; +$panel-height: 560px; +$trigger-offset-bottom: 80px; +$trigger-offset-right: 20px; + +.bee-monitor { + position: fixed; + z-index: 700; + pointer-events: none; + &--open { pointer-events: auto; } +} + +.bee-monitor__button { + position: fixed; + bottom: $trigger-offset-bottom; + right: $trigger-offset-right; + width: 44px; + height: 44px; + border-radius: 50%; + border: 1px solid var(--bitfun-border-subtle); + background: var(--bitfun-bg-secondary); + color: var(--bitfun-text-secondary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.30); + transition: background 0.2s, color 0.2s, transform 0.2s; + pointer-events: auto; + z-index: 701; + &:hover { + background: var(--bitfun-element-hover); + color: var(--bitfun-accent); + transform: scale(1.08); + } + .bee-monitor--open & { opacity: 0; pointer-events: none; transform: scale(0.8); } +} + +.bee-monitor__backdrop { + position: fixed; + inset: 0; + background: transparent; + z-index: 698; +} + +.bee-monitor__panel { + position: fixed; + bottom: 136px; + right: $trigger-offset-right; + width: $panel-width; + height: $panel-height; + background: var(--bitfun-bg); + border: 1px solid var(--bitfun-border-subtle); + border-radius: 10px; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + z-index: 699; + overflow: hidden; + transform: translateY(16px) scale(0.96); + opacity: 0; + pointer-events: none; + transition: transform 0.22s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.18s ease; + &--open { transform: translateY(0) scale(1); opacity: 1; pointer-events: auto; } + &--maximized { + top: 40px; left: 40px; right: 40px; bottom: 40px; + width: auto; height: auto; + } +} + +.bee-monitor__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--bitfun-border-subtle); + flex-shrink: 0; + background: var(--bitfun-bg-secondary); +} +.bee-monitor__title { + font-size: 13px; font-weight: 600; + color: var(--bitfun-text); +} +.bee-monitor__header-actions { display: flex; gap: 4px; } +.bee-monitor__header-btn { + width: 28px; height: 28px; border-radius: 6px; + border: none; background: transparent; + color: var(--bitfun-text-secondary); cursor: pointer; + display: flex; align-items: center; justify-content: center; + &:hover { background: var(--bitfun-element-hover); color: var(--bitfun-text); } + &--close:hover { background: rgba(239,68,68,0.15); color: var(--bitfun-error); } +} + +.bee-monitor__body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } +.bee-monitor__loading, .bee-monitor__error { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 8px; + padding: 24px; text-align: center; + color: var(--bitfun-text-muted); font-size: 13px; + small { font-size: 11px; opacity: 0.7; } +} \ No newline at end of file diff --git a/src/web-ui/src/app/layout/BeeColonyMonitor.tsx b/src/web-ui/src/app/layout/BeeColonyMonitor.tsx new file mode 100644 index 0000000000..ff1f90ce68 --- /dev/null +++ b/src/web-ui/src/app/layout/BeeColonyMonitor.tsx @@ -0,0 +1,147 @@ +/** + * BeeColonyMonitor — fixed floating panel that renders the bee-colony-dag + * MiniApp DAG visualization. Always accessible via a nav button; stays + * visible alongside other content without taking a full scene tab. + * + * Pattern: FloatingMiniChat-style floating panel with MiniAppRunner inside. + */ +import React, { useState, useCallback, useEffect, useMemo } from 'react'; +import { GitBranch, X, Minimize2, Maximize2 } from 'lucide-react'; +import { miniAppAPI } from '@/infrastructure/api/service-api/MiniAppAPI'; +import type { MiniApp } from '@/infrastructure/api/service-api/MiniAppAPI'; +import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { createLogger } from '@/shared/utils/logger'; +import MiniAppRunner from '@/app/scenes/miniapps/components/MiniAppRunner'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import './BeeColonyMonitor.scss'; + +const log = createLogger('BeeColonyMonitor'); + +const BEE_COLONY_APP_ID = 'bee-colony-dag'; + +export const BeeColonyMonitor: React.FC = () => { + const { themeType } = useTheme(); + const { workspacePath } = useCurrentWorkspace(); + const activeTabId = useSceneStore((s) => s.activeTabId); + + const [isOpen, setIsOpen] = useState(false); + const [app, setApp] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [maximized, setMaximized] = useState(false); + + // Only show in agent scene (where the DAG is relevant) + const isAgentScene = useMemo( + () => typeof activeTabId === 'string' && activeTabId.startsWith('agentic:'), + [activeTabId], + ); + + const loadApp = useCallback(async () => { + setLoading(true); + setError(null); + try { + const loaded = await miniAppAPI.getMiniApp( + BEE_COLONY_APP_ID, + themeType ?? 'dark', + workspacePath || undefined, + ); + if (!loaded?.compiled_html?.trim()) { + setError('MiniApp not compiled'); + setApp(null); + return; + } + setApp(loaded); + } catch (err) { + log.error('Failed to load bee colony MiniApp', err); + setError(String(err)); + setApp(null); + } finally { + setLoading(false); + } + }, [themeType, workspacePath]); + + // Load app when panel opens + useEffect(() => { + if (isOpen && !app && !loading) { + void loadApp(); + } + }, [isOpen, app, loading, loadApp]); + + const handleToggle = useCallback(() => { + setIsOpen((prev) => !prev); + }, []); + + const handleClose = useCallback(() => { + setIsOpen(false); + }, []); + + // Don't render in non-agent scenes + if (!isAgentScene) return null; + + return ( +
+ {/* Backdrop */} + {isOpen &&
} + + {/* Trigger button — always visible in agent scenes */} + + + {/* Floating panel */} +
+ {/* Header */} +
+ 蜂群架构监控 +
+ + +
+
+ + {/* Body */} +
+ {loading && ( +
加载中...
+ )} + {error && !app && ( +
+

蜂群 MiniApp 未就绪

+ {error}. 请确保已编译部署。 +
+ )} + {app && } +
+
+
+ ); +}; + +export default BeeColonyMonitor; From 8308ba34e0eeb712fd2817a5bbdfe40004d0e885 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 14:09:28 +0800 Subject: [PATCH 44/48] fix: remove stray braces from unsafe-block cleanup in computer_use --- pr-body-original.md | 241 ++++++++++++++++++ .../src/computer_use/windows_ax_shortcuts.rs | 2 +- .../desktop/src/computer_use/windows_ax_ui.rs | 4 +- .../src/computer_use/windows_bg_input.rs | 2 +- .../src/computer_use/windows_list_apps.rs | 4 +- .../desktop/src/computer_use/windows_msaa.rs | 6 +- 6 files changed, 250 insertions(+), 9 deletions(-) create mode 100644 pr-body-original.md diff --git a/pr-body-original.md b/pr-body-original.md new file mode 100644 index 0000000000..daf15bfb88 --- /dev/null +++ b/pr-body-original.md @@ -0,0 +1,241 @@ + +## 免责声明 + +- **Vibe Coding**: 本 PR 全部代码由 AI 生成,提交者无编程背景,仅提供设计思路、架构方向与代码示例参考。 +- **开发环境**: 全程使用 BitFun + DeepSeek V4 Pro 对话开发,通过 BitFun DeepReview 进行代码审核。 +- **实验性质**: 本 PR 是探索性框架示例,旨在验证 BitFun 原生基础设施是否支持复杂长任务的 Multi-Agent Workflow 编排能力。并非生产级实现,希望可为官方功能更新提供参考。 +- **测试程度**: AI 辅助完成,已通过 `cargo check --workspace` + `pnpm run type-check:web` + Team 模式端到端功能验证。未经过完整单元测试覆盖。 + +--- + +## 核心设计思路 + +**不积跬步,无以至千里;千里之堤,溃于蚁穴。** 长任务实现的本质不是"一个 Agent 扛到底",而是把每一步都做对——每一步过审查、每一步纠偏、每一步确定性输出。 + +**原子任务分解**: 任何复杂任务都可分解为不可再分的原子步骤。分解粒度决定并行度——可并行的并行(提效),有依赖的串行(保流程)。 + +**三蜂 Loop 循环**: 每个原子任务由提示蜂+执行蜂+审查蜂三个 Agent 通过 SessionMessage 自循环。Loop 确保执行上下文最大化节省 token,不通过 Gate 绝不输出到下一步。审查蜂不是"最后看一眼",是每一步都盯——偷懒、幻觉、死循环、跳过验证——实时拦截+纠正注入。 + +**事实、效率、结果**: 三个不可妥协的维度。事实 = 每一步基于证据,不做假设;效率 = 最大化并行,最小化空转;结果 = Gate 通过才是输出,不通过就是回退。 + +**只调度不执行**: 军团长分解任务、拓扑排序、并行派发、Gate 裁决。不亲手改一行代码。 + +**Mesh 网状通信**: 军团成员之间 SessionMessage 直连(不经军团长),形成真正的对等网络。 + +**现成积木最大化复用**: DeepReview 审查团队已是完整三蜂结构。Claw 已有 session 工具。不做新引擎——泛化现有结构即可支持任意任务编排。不引入外部依赖。 + +**每一步都是正确的**: 行为守卫生效(Hook Abort)+ 审查蜂实时行为审计 + Gate Loop 层层把关 → 长任务才敢放心交出去。 + +--- + +## 蜂群架构:多Agent军团编排 + 审查Hook + DAG画布 + +### 一、核心架构思想 + +**公司(分解派发) + 军事(容错重组) + 航天(追溯零错)** 三域合一,通过八个固定Agent角色实现任意复杂度的任务编排。 + +#### 八个固定角色(积木) +| 角色 | 二元决策树 | 职责 | +|------|-----------|------| +| 指挥官 Commander | 树1-3 P→C→D | 感知全局→认知根因→决策方向,只指挥不执行 | +| 秘书 B01 Secretary | L3库检索 | 按任务领域检索提示词/技能/框架/规则,输出注入包 | +| 产品经理 ProductManager | R-ID需求定义 | 方向→结构化需求文档,分配追溯ID+验收标准 | +| 规划师 Planner | 树4-6 P→D→C | 需求→原子步分解,判依赖→串行/并行派发 | +| 执行者 Executor | 树7-10 O→O→D→A | 观察→思考→决策→执行,收到退回自动切Debug模式 | +| 审查者 Reviewer | 三节点审查 | ①功能审查(QA) ②安全审查(OWASP/CVE) ③Debug(诊断→退回) | +| 验收者 Acceptor | R-ID逐项闭合 | 对照需求文档按R-ID验证,全部闭合才交付 | +| 优化者 Optimizer | 复盘+归档+知识库 | 复盘进化→更新模板,新知识→写入L3库,产物→命名归档 | + +#### 六条原则 +> 串行保流程,并行提效率,循环促执行,节点控准确,原子降难度,嵌套解万物 + +#### 10棵二元决策树 +每棵只有两条路:通过→下一棵,不通过→修正→重检→直到通过。任何节点可展开为完整子链(自由嵌套)。 + +#### 与ruflo的本质差异 +角色固定(八个通用骨架),提示词灵活。同一executor注入量化提示词→量化执行者,注入前端提示词→前端执行者。B01管理领域提示词库。 + +--- + +### 二、LegionControl 军团编排 + +**动态DAG拓扑排序执行引擎。** 读JSON模板→拓扑排序→分层创建session→注入上下文→串行/并行派发。 + +#### 核心实现 +- `LegionControl` 工具:load模板 / list / status,支持自定义agent类型 +- 拓扑排序算法:条件边跳过(空condition字段不参与环检测),分层并发创建 +- `SessionControl`:Collapsed→Expanded,支持动态创建/列出/关闭会话 +- `SessionMessage`:跨会话消息派发,传workspace路径和agent类型 +- `SessionHistory`:导出会话转录,支持turn选择器(单轮/范围/最后N轮) + +#### 预设模板 +- `bee-colony-standard`:8节点全链(cmd→sec→pm→plan→exec→review→accept→opt) +- `bee-colony-quick`:4节点精简链(cmd→exec→review→accept) +- `bee-colony-parallel`:12节点3并行执行者+各自审查 +- `bee-colony-single`:单执行者模式 +- `bee-colony-multi-legion`:14节点多军团接力 + +--- + +### 三、cc-haha 模式审查Hook + +**每轮对话结束触发LLM审查(非自审,非Agent会话),审查结果注入下一轮上下文。** + +``` +每轮(有工具调用) + │ + ├─ stop hook: 检查 REVIEW_BUFFER → 注入上轮审查结果 + │ ABORT:* → HookResult::Abort(硬拦截) + │ CTX:* → [书记官] 上下文恢复 + │ SKILL:* → [提示蜂] 技能推荐 + │ WARN:* → [审查员] 警告 + │ + └─ std::thread → primary model → LLM审查本轮 + → 结果 push REVIEW_BUFFER → 下轮stop hook取出 +``` + +#### 审查员三合一职责 +1. **书记官(上下文守护)**:检测上下文压缩→提取压缩前关键决策/进度/用户纠正→CTX注入 +2. **纪律委员(行为审查)**:Read-before-Edit、LionHeart保护、策略死循环、PS+中文+JSON、全部工具失败、改后未验证→ABORT/WARN +3. **提示蜂(技能推荐)**:匹配Agent行为到可用BitFun技能→SKILL推荐 + +#### 设计要点 +- cc-haha模式:stop→外部进程→结果注入,不创建Agent会话,不fork上下文 +- 使用primary模型(与主Agent同款),非独立fast模型 +- 同步检查全部移除,AI全权判断 +- 平时沉默(PASS过滤),只在违规/上下文退化时出声 + +--- + +### 四、STALE_TRACKER 修复 + +新增`last_target`字段区分"同工具不同文件"和"同工具同文件"。 +- Write commander.md → Write planner.md → Write executor.md(不同target)→计数器重置 +- 修复前:Write×4连续触发误判Abort +- 修复后:target变化时计数器重置 + +--- + +### 五、蜂群DAG画布MiniApp + +#### 内置MiniApp `bee-colony-dag` +- 8节点纵向DAG(cmd→sec→pm→plan→exec→review→accept→opt) +- 4色状态:idle=灰、running=蓝(脉冲动画)、done=绿、failed=红 +- Gate节点(review/accept)红色虚线边框 +- 500ms轮询`app.storage('bee-colony-state')`实时更新 + +#### BeeColonyMonitor浮动面板 +- GitBranch按钮固定在Agent场景右下角 +- 点击展开MiniAppRunner加载蜂群DAG +- 支持最大化/还原 + +#### 状态推送工具 +`bee_colony_state_push.py`:set/reset/批量JSON三种模式,直写storage.json + +--- + +### 六、Agent注册 + +| Agent | 类型 | 文件 | +|-------|------|------| +| commander | Mode | `%APPDATA%/bitfun/agents/commander.md` | +| secretary (B01) | Subagent | `%APPDATA%/bitfun/agents/secretary.md` | +| product-manager | Subagent | `%APPDATA%/bitfun/agents/product-manager.md` | +| planner | Subagent | `%APPDATA%/bitfun/agents/planner.md` | +| executor | Subagent | `%APPDATA%/bitfun/agents/executor.md` | +| reviewer | Subagent | `%APPDATA%/bitfun/agents/reviewer.md` | +| acceptor | Subagent | `%APPDATA%/bitfun/agents/acceptor.md` | +| optimizer | Subagent | `%APPDATA%/bitfun/agents/optimizer.md` | +| bee-reviewer | Subagent | `%APPDATA%/bitfun/agents/bee-reviewer.md` | +| b01-context-agent | Subagent | `%APPDATA%/bitfun/agents/b01-context-agent.md` | +| c01-audit-agent | Subagent | `%APPDATA%/bitfun/agents/c01-audit-agent.md` | + +--- + +### 七、关键文件变更 + +| 文件 | 变更 | +|------|------| +| `legion_control_tool.rs` | 新增,拓扑排序+分层创建+条件边 | +| `post_call_hooks.rs` (core) | cc-haha审查:REVIEW_BUFFER+stop hook注入+三职合一批量处理 | +| `post_call_hooks.rs` (agent-runtime) | ToolCallSummary扩展input_preview字段 | +| `execution_engine.rs` | 每轮spawn审查LLM调用+primary模型 | +| `session_control_tool.rs` | Collapsed→Expanded曝光 | +| `session_message_tool.rs` | Collapsed→Expanded曝光 | +| `session_history_tool.rs` | Collapsed→Expanded曝光 | +| `builtin.rs` | bee-colony-dag内置注册 | +| `BeeColonyMonitor.tsx/.scss` | 新增浮动审查面板 | +| `commander.md` | 嵌入任务启动协议(B01优先) | +| `SKILL.md` v10.4 | 上下文恢复协议+三域架构+八角色完整定义 | +| `AgentsScene.tsx` | 解决main合并冲突,两边import都保留 | + +--- + +## Summary + +Agentic Dynamic Workflows: 泛化 BitFun 原生会话工具,实现智能体军团编排——Agent 通过 SessionControl 创建子 Agent 会话节点,SessionMessage 发送任务并自动接收回复,SessionHistory 审查战报,Goal 追踪进度。不引入新引擎,全用原生基础设施。 + +Fixes #N/A(新功能) + +## Type and Areas + +Type: **feat** + +Areas: **Rust core, desktop/Tauri, web UI, ACP interface** + +## Motivation / Impact + +**问题**: Team 模式的 SessionControl/SessionMessage/SessionHistory 三个工具处于 Collapsed 状态,Agent 看不到。session 工具的 agent_type 硬编码,ACP 外部 Agent 无法被编排。DeepReview 审查团队被限制为代码审查专用。 + +**改动**: +- 3 个 session 工具 Collapsed→Expanded,Team 模式直接可用 +- agent_type 从硬编码枚举改为 AgentRegistry 动态获取(含 ACP) +- ACP Agent 注册为 Mode,出现在智能体选择器中 +- LegionControl 工具:读军团模板 JSON,拓扑排序,一键创建多个 Agent 会话 +- Hook 框架加 Abort 能力 + BehaviorGuard 行为检测 +- team_mode.md 重写:从 gstack skills(316行)→ 军团指挥系统(108行)+ Gate Loop Protocol + +**影响**: Team/Claw/agentic/Plan/Debug/Multitask 模式现在全部能创建和互发消息。ACP 外部 Agent 可被编排。审查蜂可见性保持不变。 + +## Verification + +```bash +# Rust +cargo check --workspace # 零 error,零 warning + +# TypeScript +pnpm run type-check:web # 零 error + +# E2E 功能验证(Team 模式) +SessionControl(action:"list") ✅ 列出 11 个会话 +SessionControl(action:"create") ✅ 创建 agentic 节点 +SessionMessage(session_id, message) ✅ Plan/agentic/ACP 通信闭环 +SessionHistory(session_id) ✅ 战报导出 +LegionControl(action:"list"/"load") ✅ 列表+拓扑创建 +Goal get/create/update/complete ✅ 追踪 +ACP agent_type 动态注册 ✅ 13 个 Agent 入注册表 +LegionControl 条件边跳过 ✅ fail 边不参与循环检测 +Hook Abort 框架编译 ✅ 无 error +``` + +## Reviewer Notes + +**架构边界**: 所有改动严格遵循 BitFun 六层架构: +- contracts: 未改 +- execution: HookResult::Abort + BehaviorGuard(post_call_hooks.rs) +- services: 未改 +- adapters/ACP: AcpAgent 注册(manager.rs) +- assembly/core: 工具曝光 + 动态 agent_type + LegionControl + prompt 重写 +- interfaces: ACP agent 入前端核心区 + +**不引入新引擎**: 全用 SessionControl/SessionMessage/SessionHistory/Goal/Task 原生工具。 + +**不破坏现有功能**: DeepReview 审查流程不变,审查蜂可见性保持 Hidden/restricted 原始状态。 + +**AI 辅助**: 本 PR 由 AI 辅助完成,已通过 cargo check --workspace + type-check:web + 完整 E2E 功能验证。 + +## Checklist + +- [x] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. +- [x] Relevant verification is recorded above. +- [x] User-facing strings, docs, and locales are updated where applicable. \ No newline at end of file diff --git a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs index eaaa18d78b..00c3b893e7 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_shortcuts.rs @@ -60,7 +60,7 @@ unsafe fn build_shortcuts_cache_request( let _ = unsafe { cache_req.AddPattern(UIA_TogglePatternId) }; let _ = unsafe { cache_req.SetTreeScope(TreeScope_Subtree) }; Ok(cache_req) -}} +} fn read_cached_name(element: &IUIAutomationElement) -> Option { unsafe { diff --git a/src/apps/desktop/src/computer_use/windows_ax_ui.rs b/src/apps/desktop/src/computer_use/windows_ax_ui.rs index ccac8bc0e8..b35bd760c2 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_ui.rs @@ -182,7 +182,7 @@ unsafe fn build_cache_request( } Ok(cache_req) -}} +} /// `BuildUpdatedCache` with a short retry loop. A single transient provider /// error (commonly `E_FAIL` / `0x80004005` from a control rebuilding its @@ -467,7 +467,7 @@ unsafe fn walk_tree_full( let tree_text = render_lines(&lines); Ok((tree_text, nodes)) -}} +} #[allow(clippy::too_many_arguments)] unsafe fn walk_cached_bounded( diff --git a/src/apps/desktop/src/computer_use/windows_bg_input.rs b/src/apps/desktop/src/computer_use/windows_bg_input.rs index b1540987a1..8e8619b9b4 100644 --- a/src/apps/desktop/src/computer_use/windows_bg_input.rs +++ b/src/apps/desktop/src/computer_use/windows_bg_input.rs @@ -580,7 +580,7 @@ pub(super) fn inject_key_cloaked(hwnd: HWND, keycode: u16, modifiers: &[u16]) -> /// # Safety /// `process` must be a valid `HANDLE` (or the `GetCurrentProcess()` pseudo- /// handle) with `TOKEN_QUERY` access for `OpenProcessToken` to succeed. -unsafe fn process_integrity_rid(process: Handle) -> Option { unsafe { +unsafe fn process_integrity_rid(process: Handle) -> Option { let mut token: Handle = std::ptr::null_mut(); // SAFETY: the caller supplies a process handle valid for `TOKEN_QUERY`; // `token` is a live out-pointer for the duration of the call. diff --git a/src/apps/desktop/src/computer_use/windows_list_apps.rs b/src/apps/desktop/src/computer_use/windows_list_apps.rs index 041441dc69..b090de651a 100644 --- a/src/apps/desktop/src/computer_use/windows_list_apps.rs +++ b/src/apps/desktop/src/computer_use/windows_list_apps.rs @@ -109,7 +109,7 @@ pub(super) fn find_top_window_for_pid(pid: u32) -> Option { return windows::Win32::Foundation::FALSE; } TRUE - }} + } let mut state = FindState { target_pid: pid, found: None, @@ -166,7 +166,7 @@ unsafe extern "system" fn enum_windows_cb(hwnd: HWND, lparam: LPARAM) -> BOOL { s.windows.push(WindowEntry { pid, title }); } TRUE -}} +} /// Resolve the full image path of `pid` and return its `.exe` basename. fn exe_basename_for_pid(pid: u32) -> Option { diff --git a/src/apps/desktop/src/computer_use/windows_msaa.rs b/src/apps/desktop/src/computer_use/windows_msaa.rs index 16cb9164b0..858d083687 100644 --- a/src/apps/desktop/src/computer_use/windows_msaa.rs +++ b/src/apps/desktop/src/computer_use/windows_msaa.rs @@ -219,7 +219,7 @@ unsafe fn walk( total: &mut usize, max_depth: usize, max_total: usize, -) { unsafe { +) { if depth >= max_depth || *total >= max_total { return; } @@ -374,7 +374,7 @@ unsafe fn walk( } } } -}} +} /// Construct a `VT_I4` VARIANT carrying `id` (used for `CHILDID_SELF` and child /// indices). The `windows` 0.61 crate exposes `VARIANT` as a `#[repr(C)]` struct @@ -406,7 +406,7 @@ unsafe fn variant_to_i32(v: &VARIANT) -> Option { None } } -}} +} /// Map an MSAA role id to a `control_type` string matching the UIA path. For /// roles not in this list we emit `Role_` so the agent still sees something From 63cc91468ae136a1b674bb780c141d828a44e485 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 14:54:26 +0800 Subject: [PATCH 45/48] fix: prevent bee-review context accumulation and remove redundant unsafe blocks --- .../desktop/src/computer_use/windows_ax_ui.rs | 8 ++++---- .../desktop/src/computer_use/windows_bg_input.rs | 12 ++++++------ .../desktop/src/computer_use/windows_capture.rs | 8 ++++---- .../desktop/src/computer_use/windows_msaa.rs | 8 ++++---- .../src/computer_use/windows_wgc_capture.rs | 12 ++++++------ .../webdriver/src/platform/evaluator/windows.rs | 4 ++-- .../src/agentic/execution/execution_engine.rs | 16 +++++++++++++++- 7 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/apps/desktop/src/computer_use/windows_ax_ui.rs b/src/apps/desktop/src/computer_use/windows_ax_ui.rs index b35bd760c2..b87ef612d9 100644 --- a/src/apps/desktop/src/computer_use/windows_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/windows_ax_ui.rs @@ -191,7 +191,7 @@ unsafe fn build_cache_request( pub(crate) unsafe fn build_updated_cache_with_retry( uncached: &IUIAutomationElement, cache_req: &IUIAutomationCacheRequest, -) -> BitFunResult { unsafe { +) -> BitFunResult { let mut attempt = 0u32; loop { // SAFETY: both COM interfaces are live for the call and `cache_req` @@ -216,7 +216,7 @@ pub(crate) unsafe fn build_updated_cache_with_retry( } } } -}} +} // ── Cached property readers ───────────────────────────────────────────────── // @@ -480,7 +480,7 @@ unsafe fn walk_cached_bounded( total: &mut usize, max_elements: usize, max_depth: usize, -) { unsafe { +) { if depth > max_depth || *total >= max_elements { return; } @@ -586,7 +586,7 @@ unsafe fn walk_cached_bounded( } } } -}} +} // ── Rendering ────────────────────────────────────────────────────────────── diff --git a/src/apps/desktop/src/computer_use/windows_bg_input.rs b/src/apps/desktop/src/computer_use/windows_bg_input.rs index 8e8619b9b4..7a7f1037e2 100644 --- a/src/apps/desktop/src/computer_use/windows_bg_input.rs +++ b/src/apps/desktop/src/computer_use/windows_bg_input.rs @@ -438,7 +438,7 @@ fn post_char(hwnd: HWND, ch: char) -> BitFunResult<()> { /// and is visually hidden (not rendered) while still receiving messages, so the /// brief foreground swap in the cloaked-injection path is invisible to the /// user. Best-effort; returns whether the attribute was set. -unsafe fn set_cloak(h: HWND, on: bool) -> bool { unsafe { +unsafe fn set_cloak(h: HWND, on: bool) -> bool { let v: BOOL = if on { TRUE } else { FALSE }; // SAFETY: `v` is a live `BOOL` whose pointer and byte length match the // `DWMWA_CLOAK` contract; an invalid HWND is reported as an API error. @@ -451,7 +451,7 @@ unsafe fn set_cloak(h: HWND, on: bool) -> bool { unsafe { ) } .is_ok() -}} +} /// Bring `target` to the foreground using the `AttachThreadInput` trick, which /// inherits the current foreground thread's FG-lock token so the swap is @@ -903,7 +903,7 @@ fn vk_event(vk: u16, scan: u32, up: bool) -> Input { /// # Safety /// `SendInput` reads `ev.len()` `INPUT` records from `ev.as_ptr()`; every /// record is fully initialized above. `cbSize` is the true `size_of::`. -unsafe fn send_unicode(text: &str) -> BitFunResult<()> { unsafe { +unsafe fn send_unicode(text: &str) -> BitFunResult<()> { let mut ev: Vec = Vec::with_capacity(text.len() * 2); for u in text.encode_utf16() { ev.push(unicode_event(u, false)); @@ -926,14 +926,14 @@ unsafe fn send_unicode(text: &str) -> BitFunResult<()> { unsafe { ))); } Ok(()) -}} +} /// Deliver a key + modifiers as a single `SendInput` burst: modifiers down, /// key down, key up, modifiers up (reverse). /// /// # Safety /// `SendInput` reads a fully-initialized `INPUT` array; `cbSize` is correct. -unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { unsafe { +unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { let mut ev: Vec = Vec::with_capacity(modifiers.len() * 2 + 2); for &m in modifiers { // SAFETY: `MapVirtualKeyW` accepts every virtual-key value and has no @@ -965,7 +965,7 @@ unsafe fn send_key_combo(keycode: u16, modifiers: &[u16]) -> BitFunResult<()> { ))); } Ok(()) -}} +} /// Fallback for [`inject_key_cloaked`] when foreground can't be obtained: post /// `WM_KEYDOWN` / `WM_KEYUP` to the window's queue (best-effort; may miss diff --git a/src/apps/desktop/src/computer_use/windows_capture.rs b/src/apps/desktop/src/computer_use/windows_capture.rs index 1d0832b2ac..b2715086e7 100644 --- a/src/apps/desktop/src/computer_use/windows_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_capture.rs @@ -200,7 +200,7 @@ fn screenshot_window_via_wgc(hwnd: HWND) -> BitFunResult<(Vec, u32, u32)> { /// Works for UWP / DirectComposition surfaces that `PrintWindow` can't reach, /// as long as the window is on-screen and not occluded. Returns /// `(bgra_pixels, width, height)`. -unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { unsafe { +unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32, i32)> { let mut rect = RECT::default(); // SAFETY: `rect` is a valid out-parameter and a stale/invalid HWND is // reported by the Win32 API. @@ -280,7 +280,7 @@ unsafe fn screenshot_via_screen_region(hwnd: HWND) -> BitFunResult<(Vec, i32 )); } Ok((pixels, w, h)) -}} +} /// A captured window bitmap plus the screen-space geometry it maps to. /// @@ -317,7 +317,7 @@ pub(super) fn screenshot_window_capture(hwnd: HWND) -> BitFunResult BitFunResult { unsafe { +unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult { if hwnd.is_invalid() { return Err(BitFunError::service( "screenshot_window_bytes: invalid HWND", @@ -494,7 +494,7 @@ unsafe fn screenshot_window_bytes_unsafe(hwnd: HWND) -> BitFunResult BitFunResult> { unsafe { +) -> BitFunResult> { // BitFun is a Tauri GUI app; match the UIA path's apartment threading. // SAFETY: initializes COM for the current thread; the result is intentionally // ignored because an already initialized apartment is acceptable here. @@ -167,7 +167,7 @@ unsafe fn walk_bounded( ); Ok(nodes) -}} +} /// Returns `true` when `hwnd` belongs to a LibreOffice / OpenOffice VCL window /// whose UIA provider is known to hang on `BuildUpdatedCache(Subtree)` or return @@ -383,7 +383,7 @@ unsafe fn walk( /// `VARIANT_0.Anonymous` field is `ManuallyDrop` inside a union; /// the borrow checker refuses to auto-`DerefMut` it for a write, so the /// `ManuallyDrop` is dereferenced explicitly. -unsafe fn child_id_variant(id: i32) -> VARIANT { unsafe { +unsafe fn child_id_variant(id: i32) -> VARIANT { let mut var = VARIANT::default(); // SAFETY: `VARIANT::default` is initialized, and setting `vt = VT_I4` // selects the `lVal` union member written immediately afterward. @@ -392,7 +392,7 @@ unsafe fn child_id_variant(id: i32) -> VARIANT { unsafe { (*var.Anonymous.Anonymous).Anonymous.lVal = id; } var -}} +} /// Read a `VT_I4` out of a VARIANT. `get_accRole` returns `VT_I4` in practice /// (custom roles may arrive as `VT_BSTR`, which we map to `None` = unknown). diff --git a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs index 6acd868683..e5fde82153 100644 --- a/src/apps/desktop/src/computer_use/windows_wgc_capture.rs +++ b/src/apps/desktop/src/computer_use/windows_wgc_capture.rs @@ -98,7 +98,7 @@ pub(super) fn capture_window_bgra(hwnd: HWND) -> BitFunResult<(Vec, u32, u32 } } -unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { unsafe { +unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceContext)> { let mut device: Option = None; let mut context: Option = None; let flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; @@ -143,9 +143,9 @@ unsafe fn create_d3d11_device() -> BitFunResult<(ID3D11Device, ID3D11DeviceConte BitFunError::service("D3D11CreateDevice returned null context".to_string()) })?; Ok((device, context)) -}} +} -unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { unsafe { +unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult { let dxgi_device: IDXGIDevice = d3d_device .cast() .map_err(|e| BitFunError::service(format!("IDXGIDevice cast: {e}")))?; @@ -156,13 +156,13 @@ unsafe fn create_winrt_d3d_device(d3d_device: &ID3D11Device) -> BitFunResult BitFunResult<(Vec, u32, u32)> { unsafe { +) -> BitFunResult<(Vec, u32, u32)> { let surface = frame .Surface() .map_err(|e| BitFunError::service(format!("WGC frame Surface: {e}")))?; @@ -227,6 +227,6 @@ unsafe fn copy_frame_to_bgra( unsafe { d3d_context.Unmap(&staging, 0) }; Ok((pixels, width, height)) -}} +} const D3D11_SDK_VERSION: u32 = 7; diff --git a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs index 59ebd9c36d..6626ad1835 100644 --- a/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs +++ b/src/crates/adapters/webdriver/src/platform/evaluator/windows.rs @@ -170,7 +170,7 @@ impl ICoreWebView2WebMessageReceivedEventHandler_Impl for WebMessageReceivedHand } } -unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { unsafe { +unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDriverErrorResponse> { let handler: ICoreWebView2WebMessageReceivedEventHandler = WebMessageReceivedHandler.into(); // SAFETY: `EventRegistrationToken` is an FFI value initialized by WebView2, // and both COM interface references remain valid for the duration of the call. @@ -183,7 +183,7 @@ unsafe fn register_message_handler(webview: &ICoreWebView2) -> Result<(), WebDri std::mem::forget(handler); Ok(()) -}} +} fn parse_message_payload(message: &str) -> Option { if let Ok(inner) = serde_json::from_str::(message) { diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 8cd0ea01b0..b04f8599b1 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -3420,7 +3420,21 @@ impl ExecutionEngine { } } - // Inject B01 additional contexts + // Inject B01 additional contexts — replaces previous review messages + // to prevent unbounded context growth and spurious compression triggers. + // Bee-review messages are identified by the review-prefix inside a + // block. + messages.retain(|m| { + match &m.content { + crate::agentic::core::message::MessageContent::Text(text) => { + let trimmed = text.trim(); + !(trimmed.contains("[书记官]") && trimmed.contains("")) + && !(trimmed.contains("[提示蜂]") && trimmed.contains("")) + && !(trimmed.contains("[审查员]") && trimmed.contains("")) + } + _ => true, + } + }); for ctx_msg in &aggregated.additional_contexts { let msg = Message::system(render_system_reminder(ctx_msg)); messages.push(msg); From 45131ea56557e7472e29c61d5e6937608401e1a4 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 14:58:21 +0800 Subject: [PATCH 46/48] chore: remove temporary scripts, hook-dump, and planning docs --- combine-hooks.ps1 | 44 - .../bee-colony-visual-orchestration-plan.md | 166 - docs/plans/stop-hook-dual-bee-monitoring.md | 219 - explore-cc-haha.ps1 | 49 - find-cc-haha.ps1 | 10 - find-hooks-v2.ps1 | 40 - hook-dump/COMBINED.txt | 4684 --------------- hook-dump/src_commands_hooks_hooks.tsx | 16 - hook-dump/src_costHook.ts | 25 - .../src_hooks_useDeferredHookMessages.ts | 49 - hook-dump/src_query_stopHooks.test.ts | 52 - hook-dump/src_query_stopHooks.ts | 594 -- hook-dump/src_schemas_hooks.ts | 225 - hook-dump/src_services_tools_toolHooks.ts | 655 --- hook-dump/src_types_hooks.ts | 293 - hook-dump/src_utils_hooks.ts | 5044 ----------------- .../src_utils_hooks_AsyncHookRegistry.ts | 312 - .../src_utils_hooks_apiQueryHookHelper.ts | 144 - hook-dump/src_utils_hooks_execAgentHook.ts | 342 -- hook-dump/src_utils_hooks_execHttpHook.ts | 245 - hook-dump/src_utils_hooks_execPromptHook.ts | 257 - .../src_utils_hooks_fileChangedWatcher.ts | 194 - hook-dump/src_utils_hooks_hookEvents.ts | 195 - hook-dump/src_utils_hooks_hookHelpers.ts | 86 - .../src_utils_hooks_hooksConfigManager.ts | 403 -- .../src_utils_hooks_hooksConfigSnapshot.ts | 136 - hook-dump/src_utils_hooks_hooksSettings.ts | 274 - .../src_utils_hooks_postSamplingHooks.ts | 73 - ...rc_utils_hooks_registerFrontmatterHooks.ts | 70 - .../src_utils_hooks_registerSkillHooks.ts | 67 - hook-dump/src_utils_hooks_sessionHooks.ts | 450 -- pr-body-original.md | 241 - read-hooks.ps1 | 50 - 33 files changed, 15704 deletions(-) delete mode 100644 combine-hooks.ps1 delete mode 100644 docs/plans/bee-colony-visual-orchestration-plan.md delete mode 100644 docs/plans/stop-hook-dual-bee-monitoring.md delete mode 100644 explore-cc-haha.ps1 delete mode 100644 find-cc-haha.ps1 delete mode 100644 find-hooks-v2.ps1 delete mode 100644 hook-dump/COMBINED.txt delete mode 100644 hook-dump/src_commands_hooks_hooks.tsx delete mode 100644 hook-dump/src_costHook.ts delete mode 100644 hook-dump/src_hooks_useDeferredHookMessages.ts delete mode 100644 hook-dump/src_query_stopHooks.test.ts delete mode 100644 hook-dump/src_query_stopHooks.ts delete mode 100644 hook-dump/src_schemas_hooks.ts delete mode 100644 hook-dump/src_services_tools_toolHooks.ts delete mode 100644 hook-dump/src_types_hooks.ts delete mode 100644 hook-dump/src_utils_hooks.ts delete mode 100644 hook-dump/src_utils_hooks_AsyncHookRegistry.ts delete mode 100644 hook-dump/src_utils_hooks_apiQueryHookHelper.ts delete mode 100644 hook-dump/src_utils_hooks_execAgentHook.ts delete mode 100644 hook-dump/src_utils_hooks_execHttpHook.ts delete mode 100644 hook-dump/src_utils_hooks_execPromptHook.ts delete mode 100644 hook-dump/src_utils_hooks_fileChangedWatcher.ts delete mode 100644 hook-dump/src_utils_hooks_hookEvents.ts delete mode 100644 hook-dump/src_utils_hooks_hookHelpers.ts delete mode 100644 hook-dump/src_utils_hooks_hooksConfigManager.ts delete mode 100644 hook-dump/src_utils_hooks_hooksConfigSnapshot.ts delete mode 100644 hook-dump/src_utils_hooks_hooksSettings.ts delete mode 100644 hook-dump/src_utils_hooks_postSamplingHooks.ts delete mode 100644 hook-dump/src_utils_hooks_registerFrontmatterHooks.ts delete mode 100644 hook-dump/src_utils_hooks_registerSkillHooks.ts delete mode 100644 hook-dump/src_utils_hooks_sessionHooks.ts delete mode 100644 pr-body-original.md delete mode 100644 read-hooks.ps1 diff --git a/combine-hooks.ps1 b/combine-hooks.ps1 deleted file mode 100644 index 975fa617fc..0000000000 --- a/combine-hooks.ps1 +++ /dev/null @@ -1,44 +0,0 @@ -$ErrorActionPreference = 'Continue' -$base = "E:\Yuanban\BitFun-src\hook-dump" - -# Files in priority order -$files = @( - "src_schemas_hooks.ts", - "src_utils_hooks_hooksConfigManager.ts", - "src_utils_hooks_sessionHooks.ts", - "src_utils_hooks_execPromptHook.ts", - "src_utils_hooks_execAgentHook.ts", - "src_utils_hooks_execHttpHook.ts", - "src_utils_hooks_AsyncHookRegistry.ts", - "src_utils_hooks_postSamplingHooks.ts", - "src_services_tools_toolHooks.ts", - "src_utils_hooks_hooksConfigSnapshot.ts", - "src_commands_hooks_hooks.tsx", - "src_costHook.ts", - "src_hooks_useDeferredHookMessages.ts", - "src_query_stopHooks.ts", - "src_utils_hooks_hooksSettings.ts", - "src_utils_hooks_registerFrontmatterHooks.ts", - "src_utils_hooks_registerSkillHooks.ts", - "src_utils_hooks_apiQueryHookHelper.ts", - "src_utils_hooks_fileChangedWatcher.ts", - "src_query_stopHooks.test.ts" -) - -$out = "E:\Yuanban\BitFun-src\hook-dump\COMBINED.txt" -"" | Out-File $out -Encoding utf8 - -foreach ($f in $files) { - $full = Join-Path $base $f - if (Test-Path $full) { - "`n`n========================================" | Out-File $out -Append -Encoding utf8 - "FILE: $f" | Out-File $out -Append -Encoding utf8 - "========================================" | Out-File $out -Append -Encoding utf8 - Get-Content $full | Out-File $out -Append -Encoding utf8 - } else { - "`nMISSING: $f" | Out-File $out -Append -Encoding utf8 - } -} - -Write-Host "Combined to $out" -Write-Host "Size: $((Get-Item $out).Length) bytes" diff --git a/docs/plans/bee-colony-visual-orchestration-plan.md b/docs/plans/bee-colony-visual-orchestration-plan.md deleted file mode 100644 index 7a169a93f2..0000000000 --- a/docs/plans/bee-colony-visual-orchestration-plan.md +++ /dev/null @@ -1,166 +0,0 @@ -# 蜂群架构画布 + 进度监控 - -> 2026-07-18 | MiniApp SVG DAG + Agent 推状态更新 - ---- - -## 机制 - -``` -state = { nodes: { cmd: 'idle', sec: 'idle', ..., opt: 'idle' } } - -初始化: 手动计算布局 → SVG渲染 → 全灰 (idle=#3f3f46) -执行中: Agent 每过一节点 → 写 storage.json → MiniApp poll(500ms) → render() → 节点变色 -完成: 全绿 DAG (done=#34d399) -``` - ---- - -## Phase 0: 调查 ✅ - -### 调查结论 - -| 候选通道 | 可行性 | 结论 | -|---|---|---| -| Canvas API state update | ❌ | Canvas 和 MiniApp 是两个独立系统,Canvas state 通过 `useCanvasState` + postMessage 管理,不适用于 MiniApp | -| `app.storage` 直写 | ✅ **采用** | MiniApp 通过 `app.storage.get/set` 读写 KV 存储,底层是 `//storage.json` 文件 | -| MiniApp Bridge postMessage 反向通道 | ⚠️ 可行但需改代码 | Bridge 已支持 `bitfun:event` 任意事件转发,但需 Agent 侧新增 Tauri 事件发射机制 | - -### 实际通信架构 - -``` -Agent (指挥官) - │ ExecCommand: python bee_colony_state_push.py set cmd running - ▼ -storage.json (磁盘文件) - %APPDATA%/bitfun/data/miniapps/bee-colony-dag/storage.json - │ - │ MiniApp 每 500ms 轮询 app.storage.get('bee-colony-state') - ▼ -MiniApp (iframe) - state.nodes → render() → SVG 节点变色 -``` - -### 关键文件 - -- MiniApp storage 磁盘路径: `%APPDATA%/bitfun/data/miniapps/bee-colony-dag/storage.json` -- Bridge builder: `src/crates/contracts/product-domains/src/miniapp/bridge_builder.rs` -- Bridge 前端: `src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts` -- Storage 服务: `src/crates/services/services-integrations/src/miniapp/storage.rs` -- MiniApp 管理器: `src/crates/assembly/core/src/miniapp/manager.rs` - ---- - -## Phase 1: 静态架构 DAG 渲染 ✅ - -### 产出: `MiniApp/bee-colony-dag/` - -| 文件 | 行数 | 说明 | -|---|---|---| -| `meta.json` | 35 | 权限(node.enabled=false) + i18n | -| `index.html` | 24 | Shell: header(SVG badge) + SVG canvas + footer | -| `style.css` | 163 | 深色主题 + 4色节点状态 + running脉冲动画 + gate虚线边框 | -| `ui.js` | 328 | 核心逻辑: 布局计算 + SVG render + 500ms轮询 + 徽章更新 | - -### 8 节点(标准链顺序) - -``` -cmd(指挥官) → sec(秘书B01) → pm(产品经理) → plan(规划师) - → exec(执行者) → review(审查者·Gate) → accept(验收者·Gate) → opt(优化者) -``` - -### 渲染特性 - -- 节点: 180×52px 圆角矩形, 角色名(13px bold) + 树编号(10px dim) -- 连线: 垂直箭头, Gate边红色虚线 -- Gate 节点(review/accept): 红色虚线边框叠加 -- 4 色状态: idle=#3f3f46(灰), running=#60a5fa(蓝·脉冲动画), done=#34d399(绿), failed=#ef4444(红) -- 徽章: 待命/执行中/异常/完成 - -### 与五子棋模式对比 - -| 特性 | 五子棋 | 蜂群DAG | -|---|---|---| -| 状态源 | 本地 state 对象 (用户点击) | 外部 Agent 推送 (app.storage) | -| 渲染 | renderStones() 等 SVG DOM 操作 | 同模式,render() 重绘全部节点 | -| 持久化 | app.storage 存战绩统计 | app.storage 存节点状态 | -| 刷新触发 | 用户操作后手动调 render() | 500ms 轮询检测变化后调 render() | - ---- - -## Phase 2: 状态推送 ✅ - -### Agent 端工具 - -**`%APPDATA%/bitfun/tools/bee_colony_state_push.py`** (115 行) - -```bash -# 重置全部节点为 idle -python bee_colony_state_push.py reset - -# 设置单个节点状态 -python bee_colony_state_push.py set cmd running -python bee_colony_state_push.py set cmd done "任务完成" -python bee_colony_state_push.py set exec failed "编译错误" - -# 批量推送完整状态JSON -python bee_colony_state_push.py '{"nodes":{"cmd":{"status":"done"},"sec":{"status":"running"},...}}' -``` - -### Agent 使用方式 (ExecCommand) - -``` -# 每过一个节点,执行: -ExecCommand: python C:\Users\Administrator\AppData\Roaming\bitfun\tools\bee_colony_state_push.py set - -# 例如蜂群执行标准链时: -# 1. 指挥官决策完成 → set cmd done -# 2. 秘书检索完成 → set sec done -# 3. 产品经理定义完成 → set pm done -# ...以此类推 -``` - -### MiniApp 端轮询 (ui.js 264-287行) - -```javascript -async function pollState() { - const raw = await app.storage.get("bee-colony-state"); - const hash = simpleHash(JSON.stringify(raw)); - if (hash === state.lastHash) return; // 无变化跳过 - // 更新节点状态 + render() -} -setInterval(pollState, 500); -``` - ---- - -## Phase 3: 固定展示入口 - -### 当前状态 - -MiniApp 已创建,通过以下方式之一打开: -1. BitFun 桌面端 → MiniApp 菜单 → 从文件夹导入 `MiniApp/bee-colony-dag/` -2. 或通过 `miniapp_import_from_path` Tauri 命令导入 - -### 待实现 - -- [ ] MiniApp 固定到侧边栏/底部面板 -- [ ] 参考: `MiniAppRunner.tsx` 渲染容器, `NavPanel/MiniAppEntry.tsx` 入口 -- [ ] 方案A: 复用 FloatingMiniChat 的浮动面板模式,在底部固定显示 -- [ ] 方案B: 在 NavPanel 添加"蜂群监控"快捷入口 -- [ ] 确保不随聊天对话框滚动消失 - ---- - -## 文件清单 - -| 路径 | 用途 | -|---|---| -| `docs/plans/bee-colony-visual-orchestration-plan.md` | 本计划文档 | -| `MiniApp/bee-colony-dag/meta.json` | MiniApp 元数据 + 权限 | -| `MiniApp/bee-colony-dag/index.html` | HTML Shell | -| `MiniApp/bee-colony-dag/style.css` | 深色主题样式 | -| `MiniApp/bee-colony-dag/ui.js` | DAG 渲染 + 轮询逻辑 | -| `%APPDATA%/bitfun/tools/bee_colony_state_push.py` | Agent 端状态推送工具 | -| `%APPDATA%/bitfun/legions/bee-colony-standard.json` | 蜂群标准链模板 (8节点) | -| `%APPDATA%/bitfun/agents/*.md` | 8 角色 Agent 定义 | diff --git a/docs/plans/stop-hook-dual-bee-monitoring.md b/docs/plans/stop-hook-dual-bee-monitoring.md deleted file mode 100644 index a87b6a2682..0000000000 --- a/docs/plans/stop-hook-dual-bee-monitoring.md +++ /dev/null @@ -1,219 +0,0 @@ -# Stop Hook + B01/C01 双蜂监控实现计划 - -## Background - -### 当前状态 - -BitFun 只有 **post-tool-call** 级别的 hook: - -- `SuccessfulToolPostCall`:记录 DeepReview 共享上下文 -- `BehaviorGuard`:stale strategy 检测、Read-before-Edit、LionHeart 路径保护、PowerShell+中文检测 - -所有 hook 在 `call_with_tool_runtime_hooks()`([tool_context_runtime.rs:190](src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs))中触发,仅在单个工具调用成功后执行。 - -### 缺失 - -- **无 turn/session 级别的生命周期 hook**:Stop、SessionStart、SessionEnd -- **无法在回合结束后进行整体审查**:只能逐工具检查,看不到"本轮整体有没有跑偏" -- **B01 提示蜂和 C01 审查蜂只是 SKILL.md 中的提示词级模拟**,不是真正的代码级拦截 - -### 参考设计 - -1. **cc-haha `/goal`**([goalState.ts](https://github.com/anthropics/claude-code/blob/main/src/goals/goalState.ts)):`addSessionHook(context, threadId, 'Stop', '', hook)` — PromptHook 挂载 Stop 事件,每轮回合结束后用小 LLM 评估,返回 `{ok: true/false, reason}` -2. **用户 V10 蔷薇 Harness**:Plan→Do→Check→Act 监督链,"任何 agent 不能单独做决定,每个输出至少被另一个 agent 审查过" -3. **用户 V12 梦蝶军团**:Odysseus 军师作为被动观察者,悄悄话通道通信,用户对监督 agent 无感知 - -### 核心洞察 - -B01(提示蜂)和 C01(审查蜂)本质是**同一个 Stop hook 机制**的两个 handler,区别仅在于评估 prompt: - -``` -Stop 事件触发(每回合 agent 产出后) - │ - ├─→ B01 handler: "本轮执行需要什么上下文?缺了吗?" - │ └─ 缺 → 注入补充知识 → 下一轮 - │ - └─→ C01 handler: "本轮违反了哪条铁则?" - └─ 违 → Abort + fix_instruction → 下一轮修正 -``` - -双蜂挂同一个 Stop 事件,形成一个完整的"军师"模式——沉默观察、精准出手。 - -## Implementation Approach - -### 架构变更 - -``` -execution_engine.rs: execute_dialog_turn_impl() - │ - ├─ [每 round 结束] - │ │ - │ ├─ (现有) 检查 continue_loop / max_rounds - │ │ - │ └─ (新增) run_stop_hooks(tool_name="__turn_end__", round_result, context) - │ │ - │ ├─ RuntimeHookKind::Stop → B01 提示蜂 handler - │ │ └─ 评估上下文完整性 → 注入补充提示 - │ │ - │ └─ RuntimeHookKind::Stop → C01 审查蜂 handler - │ └─ 评估铁则违规 → Abort / Continue - │ - └─ [loop 继续或终止] -``` - -### 三层改动 - -| 层 | 文件 | 改动 | -|---|---|---| -| **契约层** | `execution/agent-runtime/src/post_call_hooks.rs` | 新增 `RuntimeHookKind::Stop`;扩展 `RuntimeHookRegistry` 支持 Stop hook;新增 `StopHookExecutor` trait | -| **引擎层** | `assembly/core/src/agentic/execution/execution_engine.rs` | 在 round 结束后调用 `run_stop_hooks()` | -| **实现层** | `assembly/core/src/agentic/tools/post_call_hooks.rs` | 实现 B01 context-check handler;实现 C01 iron-rule-check handler | - -## File Change List - -### 1. `src/crates/execution/agent-runtime/src/post_call_hooks.rs` — 契约层 - -**新增 `RuntimeHookKind::Stop`**: -```rust -#[non_exhaustive] -pub enum RuntimeHookKind { - SuccessfulToolPostCall, - DeepReviewSharedContextToolUse, - BehaviorGuard, - Stop, // ← 新增 -} -``` - -**新增 `StopHookContext`**:携带回合级别的审查信息 -```rust -pub struct StopHookContext { - pub session_id: String, - pub turn_id: String, - pub round_number: u32, - pub tool_calls_in_round: Vec, // 本轮所有工具调用 - pub assistant_message_summary: String, // agent 本轮输出摘要 - pub file_reads_in_round: Vec, // 本轮读取的文件 - pub file_edits_in_round: Vec, // 本轮编辑的文件 -} -``` - -**新增 `StopHookExecutor` trait**: -```rust -pub trait StopHookExecutor { - fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult; // B01 - fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult; // C01 -} -``` - -**新增 `run_stop_hooks()` 函数**:遍历注册的 Stop handler,收集 `HookResult`。 - -### 2. `src/crates/assembly/core/src/agentic/execution/execution_engine.rs` — 引擎层 - -在 `execute_dialog_turn_impl()` 的 round loop 中,每轮结束后插入: - -```rust -// 现有:压缩上下文、检查 max_rounds、决定是否 continue -// ... - -// 新增:Stop hook 注入点 -if let Some(stop_hook) = &self.stop_hook_executor { - let ctx = StopHookContext { - session_id: ..., - turn_id: ..., - round_number: current_round, - tool_calls_in_round: collect_tool_calls_this_round(), - assistant_message_summary: summarize_assistant_output(), - file_reads_in_round: session_file_reads(), - file_edits_in_round: session_file_edits(), - }; - let result = run_stop_hooks(&ctx, stop_hook); - match result { - HookResult::Continue => { /* 正常继续 */ } - HookResult::Abort { reason, fix_instruction, .. } => { - // 注入拦截消息到下一轮 - inject_guard_message(reason, fix_instruction); - } - } -} -``` - -### 3. `src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs` — 实现层 - -**B01 提示蜂 handler(context_guard)**: -```rust -fn context_guard(&mut self, ctx: &StopHookContext) -> HookResult { - // 1. 检查本轮是否读取了要编辑的文件 → 已在 per-tool FILE_READ_TRACKER 中检查 - // 2. 检查是否有现成工具可用但被忽略 → 提示 - // 3. 检查上下文是否足够(被编辑的文件是否读了足够的行数) - // 4. 当前为 Should-level,不 Abort,只注入提示 - HookResult::Continue // 暂不拦截,仅记录 -} -``` - -**C01 审查蜂 handler(behavior_guard)**: -```rust -fn behavior_guard(&mut self, ctx: &StopHookContext) -> HookResult { - // 复用 per-tool 级别的 FILE_READ_TRACKER + STALE_TRACKER 数据 - // 从回合级别做综合判断: - - // 1. Read-before-Edit 聚合检查:本轮所有 Edit 的文件,是否都在本轮或之前 Read 过 - for edit in &ctx.file_edits_in_round { - if !ctx.file_reads_in_round.iter().any(|r| normalize_path(r) == normalize_path(edit)) { - // 已在 per-tool 级别拦截,此处做回合级聚合报告 - } - } - - // 2. 重复工具调用检测:同一工具连续 N 轮都出现 → 可能陷入循环 - // (比 per-tool 的 3 次连续调用更宏观) - - // 3. 全局扫描检查:agent 是否在钻牛角尖(心智模型1) - // 例如:连续 3 轮都只调用同一个工具类型 - - HookResult::Continue // 本期先建立框架,具体规则后续迭代 -} -``` - -### 4. `src/crates/execution/agent-runtime/src/runtime.rs` — 注册 - -在 `AgentRuntime` 构建时注册 Stop hook executor: -```rust -runtime_hook_registry - .register(RuntimeHookPlan::new("stop-bee-guard", RuntimeHookKind::Stop) - .with_order(200) - .with_timeout_millis(5_000)); -``` - -## 实施步骤 - -### Phase 1:契约层(最小改动,先跑通链路) - -1. `post_call_hooks.rs`(execution 层):加 `RuntimeHookKind::Stop`、`StopHookContext`、`StopHookExecutor` trait、`run_stop_hooks()` -2. 跑 `cargo check` 确认编译 - -### Phase 2:引擎层(挂载注入点) - -3. `execution_engine.rs`:在 round loop 结束后构造 `StopHookContext`、调用 `run_stop_hooks()` -4. 跑 `cargo check` + `cargo test -p bitfun-agent-runtime --test post_call_hook_execution_contracts` - -### Phase 3:实现层(双蜂 handler) - -5. `post_call_hooks.rs`(assembly 层):实现 B01 context_guard + C01 behavior_guard -6. 跑完整 `cargo test -p bitfun-core` - -### Phase 4:集成验证 - -7. 端到端手动验证:开启 Team 模式,确认 Stop hook 在每轮后触发 -8. 跑标准验证套件:`cargo check --workspace` + `pnpm run type-check:web` - -## 与已有代码的关系 - -- **不修改** `tool_pipeline.rs`(per-tool 级别 hook 保持不变) -- **不修改** 现有的 `BehaviorGuard`(per-tool 级别继续生效) -- **新增的 Stop hook 是回合级别的补充**,不是替代 -- Per-tool hook 和 Stop hook 形成两层防御:逐工具检查 + 回合结束后全局审查 - -## 后续扩展方向(本期不做) - -- `SessionStart` / `SessionEnd` hook:session 生命周期级审查 -- PromptHook 类型:用小 LLM(如 Haiku/DeepSeek-Flash)评估条件,替代纯 Rust 规则 -- 自动挂载 C01 审查蜂 session:Stop hook 检测到严重违规时,自动 spawn 独立审查 session diff --git a/explore-cc-haha.ps1 b/explore-cc-haha.ps1 deleted file mode 100644 index 5fd6841de0..0000000000 --- a/explore-cc-haha.ps1 +++ /dev/null @@ -1,49 +0,0 @@ -$ErrorActionPreference = 'Continue' - -# 1. Try git pull in various locations -$locations = @( - "E:\Yuanban\cc-haha", - "E:\Yuanban\cc-haha\Claude Code Haha", - "E:\Yuanban\cc-haha-src" -) - -foreach ($loc in $locations) { - Write-Host "=== Checking: $loc ===" - if (Test-Path "$loc\.git") { - Write-Host "Git repo found. Pulling..." - git -C $loc pull --rebase 2>&1 - Write-Host "Git pull done for $loc" - } else { - Write-Host "Not a git repo (no .git folder)" - } - Write-Host "" -} - -# 2. List top-level directory structure -Write-Host "=== Listing E:\Yuanban\cc-haha top-level ===" -Get-ChildItem "E:\Yuanban\cc-haha" -Depth 1 | Select-Object Name, PSIsContainer | Format-Table -AutoSize - -Write-Host "=== Listing E:\Yuanban\cc-haha-src top-level ===" -Get-ChildItem "E:\Yuanban\cc-haha-src" -Depth 1 -ErrorAction SilentlyContinue | Select-Object Name, PSIsContainer | Format-Table -AutoSize - -# 3. Find all hook-related files -Write-Host "=== Searching for hook-related files (name contains 'hook') ===" -Get-ChildItem "E:\Yuanban\cc-haha" -Recurse -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'hook' } | Select-Object FullName | Format-Table -AutoSize - -Write-Host "=== Searching for files containing 'HookResult' or 'Abort' or 'behavior_guard' ===" -$searchDirs = @("E:\Yuanban\cc-haha", "E:\Yuanban\cc-haha-src") -foreach ($dir in $searchDirs) { - if (Test-Path $dir) { - Write-Host "--- Searching in $dir ---" - Get-ChildItem $dir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { - $_.Extension -match '\.(ts|js|tsx|jsx|rs|py|go|java)$' - } | ForEach-Object { - $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue - if ($content -match 'HookResult|Abort|behavior_guard') { - Write-Host "MATCH: $($_.FullName)" - } - } - } -} - -Write-Host "=== DONE ===" diff --git a/find-cc-haha.ps1 b/find-cc-haha.ps1 deleted file mode 100644 index 8ff904f2e3..0000000000 --- a/find-cc-haha.ps1 +++ /dev/null @@ -1,10 +0,0 @@ -$ErrorActionPreference = 'Stop' -$drives = @('E:\') -foreach ($drive in $drives) { - Write-Host "Searching in $drive..." - $results = Get-ChildItem -Path $drive -Directory -Depth 2 -ErrorAction SilentlyContinue | Where-Object { $_.Name -match 'cc-haha|claude.code|CC-haha' } - foreach ($r in $results) { - Write-Host "FOUND: $($r.FullName)" - } -} -Write-Host "Search complete." diff --git a/find-hooks-v2.ps1 b/find-hooks-v2.ps1 deleted file mode 100644 index 2c2a53fd5f..0000000000 --- a/find-hooks-v2.ps1 +++ /dev/null @@ -1,40 +0,0 @@ -$ErrorActionPreference = 'Continue' -$srcDir = "E:\Yuanban\cc-haha-src" - -# Find files with "hook" in name, excluding node_modules, dist, .git -Write-Host "=== Files with 'hook' in name (source only) ===" -Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { - $_.Name -match 'hook' -and - $_.FullName -notmatch '\\node_modules\\' -and - $_.FullName -notmatch '\\dist\\' -and - $_.FullName -notmatch '\\.git\\' -and - $_.FullName -notmatch '\\__pycache__\\' -} | Select-Object FullName | Format-Table -AutoSize - -Write-Host "=== Files containing 'HookResult' (source only) ===" -Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { - $_.Extension -match '\.(ts|js|tsx|jsx)$' -and - $_.FullName -notmatch '\\node_modules\\' -and - $_.FullName -notmatch '\\dist\\' -and - $_.FullName -notmatch '\\.git\\' -} | ForEach-Object { - $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue - if ($content -match 'HookResult') { - Write-Host "HookResult: $($_.FullName)" - } -} - -Write-Host "=== Files containing 'behavior_guard' (source only) ===" -Get-ChildItem $srcDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { - $_.Extension -match '\.(ts|js|tsx|jsx)$' -and - $_.FullName -notmatch '\\node_modules\\' -and - $_.FullName -notmatch '\\dist\\' -and - $_.FullName -notmatch '\\.git\\' -} | ForEach-Object { - $content = Get-Content $_.FullName -Raw -ErrorAction SilentlyContinue - if ($content -match 'behavior_guard') { - Write-Host "behavior_guard: $($_.FullName)" - } -} - -Write-Host "=== DONE ===" diff --git a/hook-dump/COMBINED.txt b/hook-dump/COMBINED.txt deleted file mode 100644 index 738966ea18..0000000000 --- a/hook-dump/COMBINED.txt +++ /dev/null @@ -1,4684 +0,0 @@ - - - -======================================== -FILE: src_schemas_hooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\schemas\hooks.ts -LINES: 222 -========== -/** - * Hook Zod schemas extracted to break import cycles. - * - * This file contains hook-related schema definitions that were originally - * in src/utils/settings/types.ts. By extracting them here, we break the - * circular dependency between settings/types.ts and plugins/schemas.ts. - * - * Both files now import from this shared location instead of each other. - */ - -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { z } from 'zod/v4' -import { lazySchema } from '../utils/lazySchema.js' -import { SHELL_TYPES } from '../utils/shell/shellProvider.js' - -// Shared schema for the `if` condition field. -// Uses permission rule syntax (e.g., "Bash(git *)", "Read(*.ts)") to filter hooks -// before spawning. Evaluated against the hook input's tool_name and tool_input. -const IfConditionSchema = lazySchema(() => - z - .string() - .optional() - .describe( - 'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' + - 'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.', - ), -) - -// Internal factory for individual hook schemas (shared between exported -// discriminated union members and the HookCommandSchema factory) -function buildHookSchemas() { - const BashCommandHookSchema = z.object({ - type: z.literal('command').describe('Shell command hook type'), - command: z.string().describe('Shell command to execute'), - if: IfConditionSchema(), - shell: z - .enum(SHELL_TYPES) - .optional() - .describe( - "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.", - ), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific command'), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - async: z - .boolean() - .optional() - .describe('If true, hook runs in background without blocking'), - asyncRewake: z - .boolean() - .optional() - .describe( - 'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.', - ), - }) - - const PromptHookSchema = z.object({ - type: z.literal('prompt').describe('LLM prompt hook type'), - prompt: z - .string() - .describe( - 'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.', - ), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific prompt evaluation'), - // @[MODEL LAUNCH]: Update the example model ID in the .describe() strings below (prompt + agent hooks). - model: z - .string() - .optional() - .describe( - 'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - const HttpHookSchema = z.object({ - type: z.literal('http').describe('HTTP hook type'), - url: z.string().url().describe('URL to POST the hook input JSON to'), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific request'), - headers: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', - ), - allowedEnvVars: z - .array(z.string()) - .optional() - .describe( - 'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - const AgentHookSchema = z.object({ - type: z.literal('agent').describe('Agentic verifier hook type'), - // DO NOT add .transform() here. This schema is used by parseSettingsFile, - // and updateSettingsForSource round-trips the parsed result through - // JSON.stringify 鈥?a transformed function value is silently dropped, - // deleting the user's prompt from settings.json (gh-24920, CC-79). The - // transform (from #10594) wrapped the string in `(_msgs) => prompt` - // for a programmatic-construction use case in ExitPlanModeV2Tool that - // has since been refactored into VerifyPlanExecutionTool, which no - // longer constructs AgentHook objects at all. - prompt: z - .string() - .describe( - 'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.', - ), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for agent execution (default 60)'), - model: z - .string() - .optional() - .describe( - 'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - return { - BashCommandHookSchema, - PromptHookSchema, - HttpHookSchema, - AgentHookSchema, - } -} - -/** - * Schema for hook command (excludes function hooks - they can't be persisted) - */ -export const HookCommandSchema = lazySchema(() => { - const { - BashCommandHookSchema, - PromptHookSchema, - AgentHookSchema, - HttpHookSchema, - } = buildHookSchemas() - return z.discriminatedUnion('type', [ - BashCommandHookSchema, - PromptHookSchema, - AgentHookSchema, - HttpHookSchema, - ]) -}) - -/** - * Schema for matcher configuration with multiple hooks - */ -export const HookMatcherSchema = lazySchema(() => - z.object({ - matcher: z - .string() - .optional() - .describe('String pattern to match (e.g. tool names like "Write")'), // String (e.g. Write) to match values related to the hook event, e.g. tool names - hooks: z - .array(HookCommandSchema()) - .describe('List of hooks to execute when the matcher matches'), - }), -) - -/** - * Schema for hooks configuration - * The key is the hook event. The value is an array of matcher configurations. - * Uses partialRecord since not all hook events need to be defined. - */ -export const HooksSchema = lazySchema(() => - z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())), -) - -// Inferred types from schemas -export type HookCommand = z.infer> -export type BashCommandHook = Extract -export type PromptHook = Extract -export type AgentHook = Extract -export type HttpHook = Extract -export type HookMatcher = z.infer> -export type HooksSettings = Partial> - - -======================================== -FILE: src_utils_hooks_hooksConfigManager.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigManager.ts -LINES: 400 -========== -import memoize from 'lodash-es/memoize.js' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { getRegisteredHooks } from '../../bootstrap/state.js' -import type { AppState } from '../../state/AppState.js' -import { - getAllHooks, - type IndividualHookConfig, - sortMatchersByPriority, -} from './hooksSettings.js' - -export type MatcherMetadata = { - fieldToMatch: string - values: string[] -} - -export type HookEventMetadata = { - summary: string - description: string - matcherMetadata?: MatcherMetadata -} - -// Hook event metadata configuration. -// Resolver uses sorted-joined string key so that callers passing a fresh -// toolNames array each render (e.g. HooksConfigMenu) hit the cache instead -// of leaking a new entry per call. -export const getHookEventMetadata = memoize( - function (toolNames: string[]): Record { - return { - PreToolUse: { - summary: 'Before tool execution', - description: - 'Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PostToolUse: { - summary: 'After tool execution', - description: - 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PostToolUseFailure: { - summary: 'After tool execution fails', - description: - 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PermissionDenied: { - summary: 'After auto mode classifier denies a tool call', - description: - 'Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {"hookSpecificOutput":{"hookEventName":"PermissionDenied","retry":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - Notification: { - summary: 'When notifications are sent', - description: - 'Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'notification_type', - values: [ - 'permission_prompt', - 'idle_prompt', - 'auth_success', - 'elicitation_dialog', - 'elicitation_complete', - 'elicitation_response', - ], - }, - }, - UserPromptSubmit: { - summary: 'When the user submits a prompt', - description: - 'Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only', - }, - SessionStart: { - summary: 'When a new session is started', - description: - 'Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'source', - values: ['startup', 'resume', 'clear', 'compact'], - }, - }, - Stop: { - summary: 'Right before Claude concludes its response', - description: - 'Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only', - }, - StopFailure: { - summary: 'When the turn ends due to an API error', - description: - 'Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget 鈥?hook output and exit codes are ignored.', - matcherMetadata: { - fieldToMatch: 'error', - values: [ - 'rate_limit', - 'authentication_failed', - 'billing_error', - 'invalid_request', - 'server_error', - 'max_output_tokens', - 'unknown', - ], - }, - }, - SubagentStart: { - summary: 'When a subagent (Agent tool call) is started', - description: - 'Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'agent_type', - values: [], // Will be populated with available agent types - }, - }, - SubagentStop: { - summary: - 'Right before a subagent (Agent tool call) concludes its response', - description: - 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'agent_type', - values: [], // Will be populated with available agent types - }, - }, - PreCompact: { - summary: 'Before conversation compaction', - description: - 'Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['manual', 'auto'], - }, - }, - PostCompact: { - summary: 'After conversation compaction', - description: - 'Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['manual', 'auto'], - }, - }, - SessionEnd: { - summary: 'When a session is ending', - description: - 'Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'reason', - values: ['clear', 'logout', 'prompt_input_exit', 'other'], - }, - }, - PermissionRequest: { - summary: 'When a permission dialog is displayed', - description: - 'Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - Setup: { - summary: 'Repo setup hooks for init and maintenance', - description: - 'Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['init', 'maintenance'], - }, - }, - TeammateIdle: { - summary: 'When a teammate is about to go idle', - description: - 'Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only', - }, - TaskCreated: { - summary: 'When a task is being created', - description: - 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only', - }, - TaskCompleted: { - summary: 'When a task is being marked as completed', - description: - 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only', - }, - Elicitation: { - summary: 'When an MCP server requests user input (elicitation)', - description: - 'Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'mcp_server_name', - values: [], - }, - }, - ElicitationResult: { - summary: 'After a user responds to an MCP elicitation', - description: - 'Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'mcp_server_name', - values: [], - }, - }, - ConfigChange: { - summary: 'When configuration files change during a session', - description: - 'Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'source', - values: [ - 'user_settings', - 'project_settings', - 'local_settings', - 'policy_settings', - 'skills', - ], - }, - }, - InstructionsLoaded: { - summary: 'When an instruction file (CLAUDE.md or rule) is loaded', - description: - 'Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional 鈥?the paths: frontmatter patterns that matched), trigger_file_path (optional 鈥?the file Claude touched that caused the load), and parent_file_path (optional 鈥?the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.', - matcherMetadata: { - fieldToMatch: 'load_reason', - values: [ - 'session_start', - 'nested_traversal', - 'path_glob_match', - 'include', - 'compact', - ], - }, - }, - WorktreeCreate: { - summary: 'Create an isolated worktree for VCS-agnostic isolation', - description: - 'Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed', - }, - WorktreeRemove: { - summary: 'Remove a previously created worktree', - description: - 'Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only', - }, - CwdChanged: { - summary: 'After the working directory changes', - description: - 'Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - }, - FileChanged: { - summary: 'When a watched file changes', - description: - 'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - }, - } - }, - toolNames => toolNames.slice().sort().join(','), -) - -// Group hooks by event and matcher -export function groupHooksByEventAndMatcher( - appState: AppState, - toolNames: string[], -): Record> { - const grouped: Record> = { - PreToolUse: {}, - PostToolUse: {}, - PostToolUseFailure: {}, - PermissionDenied: {}, - Notification: {}, - UserPromptSubmit: {}, - SessionStart: {}, - SessionEnd: {}, - Stop: {}, - StopFailure: {}, - SubagentStart: {}, - SubagentStop: {}, - PreCompact: {}, - PostCompact: {}, - PermissionRequest: {}, - Setup: {}, - TeammateIdle: {}, - TaskCreated: {}, - TaskCompleted: {}, - Elicitation: {}, - ElicitationResult: {}, - ConfigChange: {}, - WorktreeCreate: {}, - WorktreeRemove: {}, - InstructionsLoaded: {}, - CwdChanged: {}, - FileChanged: {}, - } - - const metadata = getHookEventMetadata(toolNames) - - // Include hooks from settings files - getAllHooks(appState).forEach(hook => { - const eventGroup = grouped[hook.event] - if (eventGroup) { - // For events without matchers, use empty string as key - const matcherKey = - metadata[hook.event].matcherMetadata !== undefined - ? hook.matcher || '' - : '' - if (!eventGroup[matcherKey]) { - eventGroup[matcherKey] = [] - } - eventGroup[matcherKey].push(hook) - } - }) - - // Include registered hooks (e.g., plugin hooks) - const registeredHooks = getRegisteredHooks() - if (registeredHooks) { - for (const [event, matchers] of Object.entries(registeredHooks)) { - const hookEvent = event as HookEvent - const eventGroup = grouped[hookEvent] - if (!eventGroup) continue - - for (const matcher of matchers) { - const matcherKey = matcher.matcher || '' - - // Only PluginHookMatcher has pluginRoot; HookCallbackMatcher (internal - // callbacks like attributionHooks, sessionFileAccessHooks) does not. - if ('pluginRoot' in matcher) { - eventGroup[matcherKey] ??= [] - for (const hook of matcher.hooks) { - eventGroup[matcherKey].push({ - event: hookEvent, - config: hook, - matcher: matcher.matcher, - source: 'pluginHook', - pluginName: matcher.pluginId, - }) - } - } else if (process.env.USER_TYPE === 'ant') { - eventGroup[matcherKey] ??= [] - for (const _hook of matcher.hooks) { - eventGroup[matcherKey].push({ - event: hookEvent, - config: { - type: 'command', - command: '[ANT-ONLY] Built-in Hook', - }, - matcher: matcher.matcher, - source: 'builtinHook', - }) - } - } - } - } - } - - return grouped -} - -// Get sorted matchers for a specific event -export function getSortedMatchersForEvent( - hooksByEventAndMatcher: Record< - HookEvent, - Record - >, - event: HookEvent, -): string[] { - const matchers = Object.keys(hooksByEventAndMatcher[event] || {}) - return sortMatchersByPriority(matchers, hooksByEventAndMatcher, event) -} - -// Get hooks for a specific event and matcher -export function getHooksForMatcher( - hooksByEventAndMatcher: Record< - HookEvent, - Record - >, - event: HookEvent, - matcher: string | null, -): IndividualHookConfig[] { - // For events without matchers, hooks are stored with empty string as key - // because the record keys must be strings. - const matcherKey = matcher ?? '' - return hooksByEventAndMatcher[event]?.[matcherKey] ?? [] -} - -// Get metadata for a specific event's matcher -export function getMatcherMetadata( - event: HookEvent, - toolNames: string[], -): MatcherMetadata | undefined { - return getHookEventMetadata(toolNames)[event].matcherMetadata -} - - -======================================== -FILE: src_utils_hooks_sessionHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\sessionHooks.ts -LINES: 447 -========== -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import type { Message } from 'src/types/message.js' -import { logForDebugging } from '../debug.js' -import type { AggregatedHookResult } from '../hooks.js' -import type { HookCommand } from '../settings/types.js' -import { isHookEqual } from './hooksSettings.js' - -type OnHookSuccess = ( - hook: HookCommand | FunctionHook, - result: AggregatedHookResult, -) => void - -/** Function hook callback - returns true if check passes, false to block */ -export type FunctionHookCallback = ( - messages: Message[], - signal?: AbortSignal, -) => boolean | Promise - -/** - * Function hook type with callback embedded. - * Session-scoped only, cannot be persisted to settings.json. - */ -export type FunctionHook = { - type: 'function' - id?: string // Optional unique ID for removal - timeout?: number - callback: FunctionHookCallback - errorMessage: string - statusMessage?: string -} - -type SessionHookMatcher = { - matcher: string - skillRoot?: string - hooks: Array<{ - hook: HookCommand | FunctionHook - onHookSuccess?: OnHookSuccess - }> -} - -export type SessionStore = { - hooks: { - [event in HookEvent]?: SessionHookMatcher[] - } -} - -/** - * Map (not Record) so .set/.delete don't change the container's identity. - * Mutator functions mutate the Map and return prev unchanged, letting - * store.ts's Object.is(next, prev) check short-circuit and skip listener - * notification. Session hooks are ephemeral per-agent runtime callbacks, - * never reactively read (only getAppState() snapshots in the query loop). - * Same pattern as agentControllers on LocalWorkflowTaskState. - * - * This matters under high-concurrency workflows: parallel() with N - * schema-mode agents fires N addFunctionHook calls in one synchronous - * tick. With a Record + spread, each call cost O(N) to copy the growing - * map (O(N虏) total) plus fired all ~30 store listeners. With Map: .set() - * is O(1), return prev means zero listener fires. - */ -export type SessionHooksState = Map - -/** - * Add a command or prompt hook to the session. - * Session hooks are temporary, in-memory only, and cleared when session ends. - */ -export function addSessionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand, - onHookSuccess?: OnHookSuccess, - skillRoot?: string, -): void { - addHookToSession( - setAppState, - sessionId, - event, - matcher, - hook, - onHookSuccess, - skillRoot, - ) -} - -/** - * Add a function hook to the session. - * Function hooks execute TypeScript callbacks in-memory for validation. - * @returns The hook ID (for removal) - */ -export function addFunctionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - callback: FunctionHookCallback, - errorMessage: string, - options?: { - timeout?: number - id?: string - }, -): string { - const id = options?.id || `function-hook-${Date.now()}-${Math.random()}` - const hook: FunctionHook = { - type: 'function', - id, - timeout: options?.timeout || 5000, - callback, - errorMessage, - } - addHookToSession(setAppState, sessionId, event, matcher, hook) - return id -} - -/** - * Remove a function hook by ID from the session. - */ -export function removeFunctionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - hookId: string, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) - if (!store) { - return prev - } - - const eventMatchers = store.hooks[event] || [] - - // Remove the hook with matching ID from all matchers - const updatedMatchers = eventMatchers - .map(matcher => { - const updatedHooks = matcher.hooks.filter(h => { - if (h.hook.type !== 'function') return true - return h.hook.id !== hookId - }) - - return updatedHooks.length > 0 - ? { ...matcher, hooks: updatedHooks } - : null - }) - .filter((m): m is SessionHookMatcher => m !== null) - - const newHooks = - updatedMatchers.length > 0 - ? { ...store.hooks, [event]: updatedMatchers } - : Object.fromEntries( - Object.entries(store.hooks).filter(([e]) => e !== event), - ) - - prev.sessionHooks.set(sessionId, { hooks: newHooks }) - return prev - }) - - logForDebugging( - `Removed function hook ${hookId} for event ${event} in session ${sessionId}`, - ) -} - -/** - * Internal helper to add a hook to session state - */ -function addHookToSession( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand | FunctionHook, - onHookSuccess?: OnHookSuccess, - skillRoot?: string, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} } - const eventMatchers = store.hooks[event] || [] - - // Find existing matcher or create new one - const existingMatcherIndex = eventMatchers.findIndex( - m => m.matcher === matcher && m.skillRoot === skillRoot, - ) - - let updatedMatchers: SessionHookMatcher[] - if (existingMatcherIndex >= 0) { - // Add to existing matcher - updatedMatchers = [...eventMatchers] - const existingMatcher = updatedMatchers[existingMatcherIndex]! - updatedMatchers[existingMatcherIndex] = { - matcher: existingMatcher.matcher, - skillRoot: existingMatcher.skillRoot, - hooks: [...existingMatcher.hooks, { hook, onHookSuccess }], - } - } else { - // Create new matcher - updatedMatchers = [ - ...eventMatchers, - { - matcher, - skillRoot, - hooks: [{ hook, onHookSuccess }], - }, - ] - } - - const newHooks = { ...store.hooks, [event]: updatedMatchers } - - prev.sessionHooks.set(sessionId, { hooks: newHooks }) - return prev - }) - - logForDebugging( - `Added session hook for event ${event} in session ${sessionId}`, - ) -} - -/** - * Remove a specific hook from the session - * @param setAppState The function to update the app state - * @param sessionId The session ID - * @param event The hook event - * @param hook The hook command to remove - */ -export function removeSessionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - hook: HookCommand, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) - if (!store) { - return prev - } - - const eventMatchers = store.hooks[event] || [] - - // Remove the hook from all matchers - const updatedMatchers = eventMatchers - .map(matcher => { - const updatedHooks = matcher.hooks.filter( - h => !isHookEqual(h.hook, hook), - ) - - return updatedHooks.length > 0 - ? { ...matcher, hooks: updatedHooks } - : null - }) - .filter((m): m is SessionHookMatcher => m !== null) - - const newHooks = - updatedMatchers.length > 0 - ? { ...store.hooks, [event]: updatedMatchers } - : { ...store.hooks } - - if (updatedMatchers.length === 0) { - delete newHooks[event] - } - - prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks }) - return prev - }) - - logForDebugging( - `Removed session hook for event ${event} in session ${sessionId}`, - ) -} - -// Extended hook matcher that includes optional skillRoot for skill-scoped hooks -export type SessionDerivedHookMatcher = { - matcher: string - hooks: HookCommand[] - skillRoot?: string -} - -/** - * Convert session hook matchers to regular hook matchers - * @param sessionMatchers The session hook matchers to convert - * @returns Regular hook matchers (with optional skillRoot preserved) - */ -function convertToHookMatchers( - sessionMatchers: SessionHookMatcher[], -): SessionDerivedHookMatcher[] { - return sessionMatchers.map(sm => ({ - matcher: sm.matcher, - skillRoot: sm.skillRoot, - // Filter out function hooks - they can't be persisted to HookMatcher format - hooks: sm.hooks - .map(h => h.hook) - .filter((h): h is HookCommand => h.type !== 'function'), - })) -} - -/** - * Get all session hooks for a specific event (excluding function hooks) - * @param appState The app state - * @param sessionId The session ID - * @param event Optional event to filter by - * @returns Hook matchers for the event, or all hooks if no event specified - */ -export function getSessionHooks( - appState: AppState, - sessionId: string, - event?: HookEvent, -): Map { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return new Map() - } - - const result = new Map() - - if (event) { - const sessionMatchers = store.hooks[event] - if (sessionMatchers) { - result.set(event, convertToHookMatchers(sessionMatchers)) - } - return result - } - - for (const evt of HOOK_EVENTS) { - const sessionMatchers = store.hooks[evt] - if (sessionMatchers) { - result.set(evt, convertToHookMatchers(sessionMatchers)) - } - } - - return result -} - -type FunctionHookMatcher = { - matcher: string - hooks: FunctionHook[] -} - -/** - * Get all session function hooks for a specific event - * Function hooks are kept separate because they can't be persisted to HookMatcher format. - * @param appState The app state - * @param sessionId The session ID - * @param event Optional event to filter by - * @returns Function hook matchers for the event - */ -export function getSessionFunctionHooks( - appState: AppState, - sessionId: string, - event?: HookEvent, -): Map { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return new Map() - } - - const result = new Map() - - const extractFunctionHooks = ( - sessionMatchers: SessionHookMatcher[], - ): FunctionHookMatcher[] => { - return sessionMatchers - .map(sm => ({ - matcher: sm.matcher, - hooks: sm.hooks - .map(h => h.hook) - .filter((h): h is FunctionHook => h.type === 'function'), - })) - .filter(m => m.hooks.length > 0) - } - - if (event) { - const sessionMatchers = store.hooks[event] - if (sessionMatchers) { - const functionMatchers = extractFunctionHooks(sessionMatchers) - if (functionMatchers.length > 0) { - result.set(event, functionMatchers) - } - } - return result - } - - for (const evt of HOOK_EVENTS) { - const sessionMatchers = store.hooks[evt] - if (sessionMatchers) { - const functionMatchers = extractFunctionHooks(sessionMatchers) - if (functionMatchers.length > 0) { - result.set(evt, functionMatchers) - } - } - } - - return result -} - -/** - * Get the full hook entry (including callbacks) for a specific session hook - */ -export function getSessionHookCallback( - appState: AppState, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand | FunctionHook, -): - | { - hook: HookCommand | FunctionHook - onHookSuccess?: OnHookSuccess - } - | undefined { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return undefined - } - - const eventMatchers = store.hooks[event] - if (!eventMatchers) { - return undefined - } - - // Find the hook in the matchers - for (const matcherEntry of eventMatchers) { - if (matcherEntry.matcher === matcher || matcher === '') { - const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook)) - if (hookEntry) { - return hookEntry - } - } - } - - return undefined -} - -/** - * Clear all session hooks for a specific session - * @param setAppState The function to update the app state - * @param sessionId The session ID - */ -export function clearSessionHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, -): void { - setAppState(prev => { - prev.sessionHooks.delete(sessionId) - return prev - }) - - logForDebugging(`Cleared all session hooks for session ${sessionId}`) -} - - -======================================== -FILE: src_utils_hooks_execPromptHook.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execPromptHook.ts -LINES: 254 -========== -import { randomUUID } from 'crypto' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { queryModelWithoutStreaming } from '../../services/api/claude.js' -import type { ToolUseContext } from '../../Tool.js' -import type { Message } from '../../types/message.js' -import { isGoalPromptHookCommand } from '../../goals/goalState.js' -import { createAttachmentMessage } from '../attachments.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import type { HookResult } from '../hooks.js' -import { safeParseJSON } from '../json.js' -import { createUserMessage, extractTextContent } from '../messages.js' -import { getSmallFastModel } from '../model/model.js' -import type { PromptHook } from '../settings/types.js' -import { asSystemPrompt } from '../systemPromptType.js' -import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js' - -function goalHookFailureResult( - hook: PromptHook, - reason: string, -): HookResult | null { - if (!isGoalPromptHookCommand(hook.prompt)) return null - - const blockingError = `Goal evaluator failed: ${reason}. Treat the goal as incomplete and continue working toward it.` - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError, - command: hook.prompt, - }, - preventContinuation: true, - stopReason: blockingError, - } -} - -/** - * Execute a prompt-based hook using an LLM - */ -export async function execPromptHook( - hook: PromptHook, - hookName: string, - hookEvent: HookEvent, - jsonInput: string, - signal: AbortSignal, - toolUseContext: ToolUseContext, - messages?: Message[], - toolUseID?: string, -): Promise { - // Use provided toolUseID or generate a new one - const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` - try { - // Replace $ARGUMENTS with the JSON input - const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) - logForDebugging( - `Hooks: Processing prompt hook with prompt: ${processedPrompt}`, - ) - - // Create user message directly - no need for processUserInput which would - // trigger UserPromptSubmit hooks and cause infinite recursion - const userMessage = createUserMessage({ content: processedPrompt }) - - // Prepend conversation history if provided - const messagesToQuery = - messages && messages.length > 0 - ? [...messages, userMessage] - : [userMessage] - - logForDebugging( - `Hooks: Querying model with ${messagesToQuery.length} messages`, - ) - - // Query the model with Haiku - const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000 - - // Combined signal: aborts if either the hook signal or timeout triggers - const { signal: combinedSignal, cleanup: cleanupSignal } = - createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) - - try { - const response = await queryModelWithoutStreaming({ - messages: messagesToQuery, - systemPrompt: asSystemPrompt([ - `You are evaluating a hook in Claude Code. - -Your response must be a JSON object matching one of the following schemas: -1. If the condition is met, return: {"ok": true} -2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`, - ]), - thinkingConfig: { type: 'disabled' as const }, - tools: toolUseContext.options.tools, - signal: combinedSignal, - options: { - async getToolPermissionContext() { - const appState = toolUseContext.getAppState() - return appState.toolPermissionContext - }, - model: hook.model ?? getSmallFastModel(), - toolChoice: undefined, - isNonInteractiveSession: true, - hasAppendSystemPrompt: false, - agents: [], - querySource: 'hook_prompt', - mcpTools: [], - agentId: toolUseContext.agentId, - outputFormat: { - type: 'json_schema', - schema: { - type: 'object', - properties: { - ok: { type: 'boolean' }, - reason: { type: 'string' }, - }, - required: ['ok'], - additionalProperties: false, - }, - }, - }, - }) - - cleanupSignal() - - // Extract text content from response - const content = extractTextContent(response.message.content) - - // Update response length for spinner display - toolUseContext.setResponseLength(length => length + content.length) - - const fullResponse = content.trim() - logForDebugging(`Hooks: Model response: ${fullResponse}`) - - const json = safeParseJSON(fullResponse) - if (!json) { - logForDebugging( - `Hooks: error parsing response as JSON: ${fullResponse}`, - ) - const goalFailure = goalHookFailureResult( - hook, - 'response was not valid JSON', - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: 'JSON validation failed', - stdout: fullResponse, - exitCode: 1, - }), - } - } - - const parsed = hookResponseSchema().safeParse(json) - if (!parsed.success) { - logForDebugging( - `Hooks: model response does not conform to expected schema: ${parsed.error.message}`, - ) - const goalFailure = goalHookFailureResult( - hook, - `response did not match the expected schema (${parsed.error.message})`, - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Schema validation failed: ${parsed.error.message}`, - stdout: fullResponse, - exitCode: 1, - }), - } - } - - // Failed to meet condition - if (!parsed.data.ok) { - logForDebugging( - `Hooks: Prompt hook condition was not met: ${parsed.data.reason}`, - ) - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`, - command: hook.prompt, - }, - preventContinuation: true, - stopReason: parsed.data.reason, - } - } - - // Condition was met - logForDebugging(`Hooks: Prompt hook condition was met`) - return { - hook, - outcome: 'success', - message: createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - content: '', - }), - } - } catch (error) { - cleanupSignal() - - if (combinedSignal.aborted) { - const goalFailure = signal.aborted - ? null - : goalHookFailureResult(hook, 'evaluation timed out') - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'cancelled', - } - } - throw error - } - } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`) - const goalFailure = goalHookFailureResult( - hook, - errorMsg || 'evaluation failed', - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Error executing prompt hook: ${errorMsg}`, - stdout: '', - exitCode: 1, - }), - } - } -} - - -======================================== -FILE: src_utils_hooks_execAgentHook.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execAgentHook.ts -LINES: 339 -========== -import { randomUUID } from 'crypto' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { query } from '../../query.js' -import { logEvent } from '../../services/analytics/index.js' -import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' -import type { ToolUseContext } from '../../Tool.js' -import { type Tool, toolMatchesName } from '../../Tool.js' -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' -import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' -import { asAgentId } from '../../types/ids.js' -import type { Message } from '../../types/message.js' -import { createAbortController } from '../abortController.js' -import { createAttachmentMessage } from '../attachments.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import type { HookResult } from '../hooks.js' -import { createUserMessage, handleMessageFromStream } from '../messages.js' -import { getSmallFastModel } from '../model/model.js' -import { hasPermissionsToUseTool } from '../permissions/permissions.js' -import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' -import type { AgentHook } from '../settings/types.js' -import { jsonStringify } from '../slowOperations.js' -import { asSystemPrompt } from '../systemPromptType.js' -import { - addArgumentsToPrompt, - createStructuredOutputTool, - hookResponseSchema, - registerStructuredOutputEnforcement, -} from './hookHelpers.js' -import { clearSessionHooks } from './sessionHooks.js' - -/** - * Execute an agent-based hook using a multi-turn LLM query - */ -export async function execAgentHook( - hook: AgentHook, - hookName: string, - hookEvent: HookEvent, - jsonInput: string, - signal: AbortSignal, - toolUseContext: ToolUseContext, - toolUseID: string | undefined, - // Kept for signature stability with the other exec*Hook functions. - // Was used by hook.prompt(messages) before the .transform() was removed - // (CC-79) 鈥?the only consumer of that was ExitPlanModeV2Tool's - // programmatic construction, since refactored into VerifyPlanExecutionTool. - _messages: Message[], - agentName?: string, -): Promise { - const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` - - // Get transcript path from context - const transcriptPath = toolUseContext.agentId - ? getAgentTranscriptPath(toolUseContext.agentId) - : getTranscriptPath() - const hookStartTime = Date.now() - try { - // Replace $ARGUMENTS with the JSON input - const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) - logForDebugging( - `Hooks: Processing agent hook with prompt: ${processedPrompt}`, - ) - - // Create user message directly - no need for processUserInput which would - // trigger UserPromptSubmit hooks and cause infinite recursion - const userMessage = createUserMessage({ content: processedPrompt }) - const agentMessages = [userMessage] - - logForDebugging( - `Hooks: Starting agent query with ${agentMessages.length} messages`, - ) - - // Setup timeout and combine with parent signal - const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 - const hookAbortController = createAbortController() - - // Combine parent signal with timeout, and have it abort our controller - const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = - createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) - const onParentTimeout = () => hookAbortController.abort() - parentTimeoutSignal.addEventListener('abort', onParentTimeout) - - // Combined signal is just our controller's signal now - const combinedSignal = hookAbortController.signal - - try { - // Create StructuredOutput tool with our schema - const structuredOutputTool = createStructuredOutputTool() - - // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas - // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) - const filteredTools = toolUseContext.options.tools.filter( - tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), - ) - - // Use all available tools plus our structured output tool - // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents - // or entering plan mode, and filter out duplicate StructuredOutput tools - const tools: Tool[] = [ - ...filteredTools.filter( - tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), - ), - structuredOutputTool, - ] - - const systemPrompt = asSystemPrompt([ - `You are verifying a stop condition in Claude Code. Your task is to verify that the agent completed the given plan. The conversation transcript is available at: ${transcriptPath}\nYou can read this file to analyze the conversation history if needed. - -Use the available tools to inspect the codebase and verify the condition. -Use as few steps as possible - be efficient and direct. - -When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: -- ok: true if the condition is met -- ok: false with reason if the condition is not met`, - ]) - - const model = hook.model ?? getSmallFastModel() - const MAX_AGENT_TURNS = 50 - - // Create unique agentId for this hook agent - const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) - - // Create a modified toolUseContext for the agent - const agentToolUseContext: ToolUseContext = { - ...toolUseContext, - agentId: hookAgentId, - abortController: hookAbortController, - options: { - ...toolUseContext.options, - tools, - mainLoopModel: model, - isNonInteractiveSession: true, - thinkingConfig: { type: 'disabled' as const }, - }, - setInProgressToolUseIDs: () => {}, - getAppState() { - const appState = toolUseContext.getAppState() - // Add session rule to allow reading transcript file - const existingSessionRules = - appState.toolPermissionContext.alwaysAllowRules.session ?? [] - return { - ...appState, - toolPermissionContext: { - ...appState.toolPermissionContext, - mode: 'dontAsk' as const, - alwaysAllowRules: { - ...appState.toolPermissionContext.alwaysAllowRules, - session: [...existingSessionRules, `Read(/${transcriptPath})`], - }, - }, - } - }, - } - - // Register a session-level stop hook to enforce structured output - registerStructuredOutputEnforcement( - toolUseContext.setAppState, - hookAgentId, - ) - - let structuredOutputResult: { ok: boolean; reason?: string } | null = null - let turnCount = 0 - let hitMaxTurns = false - - // Use query() for multi-turn execution - for await (const message of query({ - messages: agentMessages, - systemPrompt, - userContext: {}, - systemContext: {}, - canUseTool: hasPermissionsToUseTool, - toolUseContext: agentToolUseContext, - querySource: 'hook_agent', - })) { - // Process stream events to update response length in the spinner - handleMessageFromStream( - message, - () => {}, // onMessage - we handle messages below - newContent => - toolUseContext.setResponseLength( - length => length + newContent.length, - ), - toolUseContext.setStreamMode ?? (() => {}), - () => {}, // onStreamingToolUses - not needed for hooks - ) - - // Skip streaming events for further processing - if ( - message.type === 'stream_event' || - message.type === 'stream_request_start' - ) { - continue - } - - // Count assistant turns - if (message.type === 'assistant') { - turnCount++ - - // Check if we've hit the turn limit - if (turnCount >= MAX_AGENT_TURNS) { - hitMaxTurns = true - logForDebugging( - `Hooks: Agent turn ${turnCount} hit max turns, aborting`, - ) - hookAbortController.abort() - break - } - } - - // Check for structured output in attachments - if ( - message.type === 'attachment' && - message.attachment.type === 'structured_output' - ) { - const parsed = hookResponseSchema().safeParse(message.attachment.data) - if (parsed.success) { - structuredOutputResult = parsed.data - logForDebugging( - `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, - ) - // Got structured output, abort and exit - hookAbortController.abort() - break - } - } - } - - parentTimeoutSignal.removeEventListener('abort', onParentTimeout) - cleanupCombinedSignal() - - // Clean up the session hook we registered for this agent - clearSessionHooks(toolUseContext.setAppState, hookAgentId) - - // Check if we got a result - if (!structuredOutputResult) { - // If we hit max turns, just log and return cancelled (no UI message) - if (hitMaxTurns) { - logForDebugging( - `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, - ) - logEvent('tengu_agent_stop_hook_max_turns', { - durationMs: Date.now() - hookStartTime, - turnCount, - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'cancelled', - } - } - - // For other cases (e.g., agent finished without calling structured output tool), - // just log and return cancelled (don't show error to user) - logForDebugging(`Hooks: Agent hook did not return structured output`) - logEvent('tengu_agent_stop_hook_error', { - durationMs: Date.now() - hookStartTime, - turnCount, - errorType: 1, // 1 = no structured output - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'cancelled', - } - } - - // Return result based on structured output - if (!structuredOutputResult.ok) { - logForDebugging( - `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, - ) - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, - command: hook.prompt, - }, - } - } - - // Condition was met - logForDebugging(`Hooks: Agent hook condition was met`) - logEvent('tengu_agent_stop_hook_success', { - durationMs: Date.now() - hookStartTime, - turnCount, - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'success', - message: createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - content: '', - }), - } - } catch (error) { - parentTimeoutSignal.removeEventListener('abort', onParentTimeout) - cleanupCombinedSignal() - - if (combinedSignal.aborted) { - return { - hook, - outcome: 'cancelled', - } - } - throw error - } - } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) - logEvent('tengu_agent_stop_hook_error', { - durationMs: Date.now() - hookStartTime, - errorType: 2, // 2 = general error - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Error executing agent hook: ${errorMsg}`, - stdout: '', - exitCode: 1, - }), - } - } -} - - -======================================== -FILE: src_utils_hooks_execHttpHook.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execHttpHook.ts -LINES: 242 -========== -import axios from 'axios' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import { getProxyUrl, shouldBypassProxy } from '../proxy.js' -// Import as namespace so spyOn works in tests (direct imports bypass spies) -import * as settingsModule from '../settings/settings.js' -import type { HttpHook } from '../settings/types.js' -import { ssrfGuardedLookup } from './ssrfGuard.js' - -const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS) - -/** - * Get the sandbox proxy config for routing HTTP hook requests through the - * sandbox network proxy when sandboxing is enabled. - * - * Uses dynamic import to avoid a static import cycle - * (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook). - */ -async function getSandboxProxyConfig(): Promise< - { host: string; port: number; protocol: string } | undefined -> { - const { SandboxManager } = await import('../sandbox/sandbox-adapter.js') - - if (!SandboxManager.isSandboxingEnabled()) { - return undefined - } - - // Wait for the sandbox network proxy to finish initializing. In REPL mode, - // SandboxManager.initialize() is fire-and-forget so the proxy may not be - // ready yet when the first hook fires. - await SandboxManager.waitForNetworkInitialization() - - const proxyPort = SandboxManager.getProxyPort() - if (!proxyPort) { - return undefined - } - - return { host: '127.0.0.1', port: proxyPort, protocol: 'http' } -} - -/** - * Read HTTP hook allowlist restrictions from merged settings (all sources). - * Follows the allowedMcpServers precedent: arrays concatenate across sources. - * When allowManagedHooksOnly is set in managed settings, only admin-defined - * hooks run anyway, so no separate lock-down boolean is needed here. - */ -function getHttpHookPolicy(): { - allowedUrls: string[] | undefined - allowedEnvVars: string[] | undefined -} { - const settings = settingsModule.getInitialSettings() - return { - allowedUrls: settings.allowedHttpHookUrls, - allowedEnvVars: settings.httpHookAllowedEnvVars, - } -} - -/** - * Match a URL against a pattern with * as a wildcard (any characters). - * Same semantics as the MCP server allowlist patterns. - */ -function urlMatchesPattern(url: string, pattern: string): boolean { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') - const regexStr = escaped.replace(/\*/g, '.*') - return new RegExp(`^${regexStr}$`).test(url) -} - -/** - * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header - * injection (CRLF injection) via env var values or hook-configured header - * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise - * inject a second header into the request. - */ -function sanitizeHeaderValue(value: string): string { - // eslint-disable-next-line no-control-regex - return value.replace(/[\r\n\x00]/g, '') -} - -/** - * Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env, - * but only for variable names present in the allowlist. References to variables - * not in the allowlist are replaced with empty strings to prevent exfiltration - * of secrets via project-configured HTTP hooks. - * - * The result is sanitized to strip CR/LF/NUL bytes to prevent header injection. - */ -function interpolateEnvVars( - value: string, - allowedEnvVars: ReadonlySet, -): string { - const interpolated = value.replace( - /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, - (_, braced, unbraced) => { - const varName = braced ?? unbraced - if (!allowedEnvVars.has(varName)) { - logForDebugging( - `Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`, - { level: 'warn' }, - ) - return '' - } - return process.env[varName] ?? '' - }, - ) - return sanitizeHeaderValue(interpolated) -} - -/** - * Execute an HTTP hook by POSTing the hook input JSON to the configured URL. - * Returns the raw response for the caller to interpret. - * - * When sandboxing is enabled, requests are routed through the sandbox network - * proxy which enforces the domain allowlist. The proxy returns HTTP 403 for - * blocked domains. - * - * Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that - * secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json. - * Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved; - * all other references are replaced with empty strings. - */ -export async function execHttpHook( - hook: HttpHook, - _hookEvent: HookEvent, - jsonInput: string, - signal?: AbortSignal, -): Promise<{ - ok: boolean - statusCode?: number - body: string - error?: string - aborted?: boolean -}> { - // Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics: - // undefined 鈫?no restriction; [] 鈫?block all; non-empty 鈫?must match a pattern. - const policy = getHttpHookPolicy() - if (policy.allowedUrls !== undefined) { - const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p)) - if (!matched) { - const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls` - logForDebugging(msg, { level: 'warn' }) - return { ok: false, body: '', error: msg } - } - } - - const timeoutMs = hook.timeout - ? hook.timeout * 1000 - : DEFAULT_HTTP_HOOK_TIMEOUT_MS - - const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( - signal, - { timeoutMs }, - ) - - try { - // Build headers with env var interpolation in values - const headers: Record = { - 'Content-Type': 'application/json', - } - if (hook.headers) { - // Intersect hook's allowedEnvVars with policy allowlist when policy is set - const hookVars = hook.allowedEnvVars ?? [] - const effectiveVars = - policy.allowedEnvVars !== undefined - ? hookVars.filter(v => policy.allowedEnvVars!.includes(v)) - : hookVars - const allowedEnvVars = new Set(effectiveVars) - for (const [name, value] of Object.entries(hook.headers)) { - headers[name] = interpolateEnvVars(value, allowedEnvVars) - } - } - - // Route through sandbox network proxy when available. The proxy enforces - // the domain allowlist and returns 403 for blocked domains. - const sandboxProxy = await getSandboxProxyConfig() - - // Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY). - // When set, configureGlobalAgents() has already installed a request - // interceptor that sets httpsAgent to an HttpsProxyAgent 鈥?the proxy - // handles DNS for the target. Skip the SSRF guard in that case, same - // as we do for the sandbox proxy, so that we don't accidentally block - // a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128). - const envProxyActive = - !sandboxProxy && - getProxyUrl() !== undefined && - !shouldBypassProxy(hook.url) - - if (sandboxProxy) { - logForDebugging( - `Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`, - ) - } else if (envProxyActive) { - logForDebugging( - `Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`, - ) - } else { - logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`) - } - - const response = await axios.post(hook.url, jsonInput, { - headers, - signal: combinedSignal, - responseType: 'text', - validateStatus: () => true, - maxRedirects: 0, - // Explicit false prevents axios's own env-var proxy detection; when an - // env-var proxy is configured, the global axios interceptor installed - // by configureGlobalAgents() handles it via httpsAgent instead. - proxy: sandboxProxy ?? false, - // SSRF guard: validate resolved IPs, block private/link-local ranges - // (but allow loopback for local dev). Skipped when any proxy is in - // use 鈥?the proxy performs DNS for the target, and applying the - // guard would instead validate the proxy's own IP, breaking - // connections to corporate proxies on private networks. - lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup, - }) - - cleanup() - - const body = response.data ?? '' - logForDebugging( - `Hooks: HTTP hook response status ${response.status}, body length ${body.length}`, - ) - - return { - ok: response.status >= 200 && response.status < 300, - statusCode: response.status, - body, - } - } catch (error) { - cleanup() - - if (combinedSignal.aborted) { - return { ok: false, body: '', aborted: true } - } - - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' }) - return { ok: false, body: '', error: errorMsg } - } -} - - -======================================== -FILE: src_utils_hooks_AsyncHookRegistry.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\AsyncHookRegistry.ts -LINES: 309 -========== -import type { - AsyncHookJSONOutput, - HookEvent, - SyncHookJSONOutput, -} from 'src/entrypoints/agentSdkTypes.js' -import { logForDebugging } from '../debug.js' -import type { ShellCommand } from '../ShellCommand.js' -import { invalidateSessionEnvCache } from '../sessionEnvironment.js' -import { jsonParse, jsonStringify } from '../slowOperations.js' -import { emitHookResponse, startHookProgressInterval } from './hookEvents.js' - -export type PendingAsyncHook = { - processId: string - hookId: string - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - startTime: number - timeout: number - command: string - responseAttachmentSent: boolean - shellCommand?: ShellCommand - stopProgressInterval: () => void -} - -// Global registry state -const pendingHooks = new Map() - -export function registerPendingAsyncHook({ - processId, - hookId, - asyncResponse, - hookName, - hookEvent, - command, - shellCommand, - toolName, - pluginId, -}: { - processId: string - hookId: string - asyncResponse: AsyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - command: string - shellCommand: ShellCommand - toolName?: string - pluginId?: string -}): void { - const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s - logForDebugging( - `Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`, - ) - const stopProgressInterval = startHookProgressInterval({ - hookId, - hookName, - hookEvent, - getOutput: async () => { - const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput - if (!taskOutput) { - return { stdout: '', stderr: '', output: '' } - } - const stdout = await taskOutput.getStdout() - const stderr = taskOutput.getStderr() - return { stdout, stderr, output: stdout + stderr } - }, - }) - pendingHooks.set(processId, { - processId, - hookId, - hookName, - hookEvent, - toolName, - pluginId, - command, - startTime: Date.now(), - timeout, - responseAttachmentSent: false, - shellCommand, - stopProgressInterval, - }) -} - -export function getPendingAsyncHooks(): PendingAsyncHook[] { - return Array.from(pendingHooks.values()).filter( - hook => !hook.responseAttachmentSent, - ) -} - -async function finalizeHook( - hook: PendingAsyncHook, - exitCode: number, - outcome: 'success' | 'error' | 'cancelled', -): Promise { - hook.stopProgressInterval() - const taskOutput = hook.shellCommand?.taskOutput - const stdout = taskOutput ? await taskOutput.getStdout() : '' - const stderr = taskOutput?.getStderr() ?? '' - hook.shellCommand?.cleanup() - emitHookResponse({ - hookId: hook.hookId, - hookName: hook.hookName, - hookEvent: hook.hookEvent, - output: stdout + stderr, - stdout, - stderr, - exitCode, - outcome, - }) -} - -export async function checkForAsyncHookResponses(): Promise< - Array<{ - processId: string - response: SyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - stdout: string - stderr: string - exitCode?: number - }> -> { - const responses: { - processId: string - response: SyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - stdout: string - stderr: string - exitCode?: number - }[] = [] - - const pendingCount = pendingHooks.size - logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`) - - // Snapshot hooks before processing 鈥?we'll mutate the map after. - const hooks = Array.from(pendingHooks.values()) - - const settled = await Promise.allSettled( - hooks.map(async hook => { - const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? '' - const stderr = hook.shellCommand?.taskOutput.getStderr() ?? '' - logForDebugging( - `Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`, - ) - - if (!hook.shellCommand) { - logForDebugging( - `Hooks: Hook ${hook.processId} has no shell command, removing from registry`, - ) - hook.stopProgressInterval() - return { type: 'remove' as const, processId: hook.processId } - } - - logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`) - - if (hook.shellCommand.status === 'killed') { - logForDebugging( - `Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`, - ) - hook.stopProgressInterval() - hook.shellCommand.cleanup() - return { type: 'remove' as const, processId: hook.processId } - } - - if (hook.shellCommand.status !== 'completed') { - return { type: 'skip' as const } - } - - if (hook.responseAttachmentSent || !stdout.trim()) { - logForDebugging( - `Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`, - ) - hook.stopProgressInterval() - return { type: 'remove' as const, processId: hook.processId } - } - - const lines = stdout.split('\n') - logForDebugging( - `Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`, - ) - - const execResult = await hook.shellCommand.result - const exitCode = execResult.code - - let response: SyncHookJSONOutput = {} - for (const line of lines) { - if (line.trim().startsWith('{')) { - logForDebugging( - `Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`, - ) - try { - const parsed = jsonParse(line.trim()) - if (!('async' in parsed)) { - logForDebugging( - `Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`, - ) - response = parsed - break - } - } catch { - logForDebugging( - `Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`, - ) - } - } - } - - hook.responseAttachmentSent = true - await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error') - - return { - type: 'response' as const, - processId: hook.processId, - isSessionStart: hook.hookEvent === 'SessionStart', - payload: { - processId: hook.processId, - response, - hookName: hook.hookName, - hookEvent: hook.hookEvent, - toolName: hook.toolName, - pluginId: hook.pluginId, - stdout, - stderr, - exitCode, - }, - } - }), - ) - - // allSettled 鈥?isolate failures so one throwing callback doesn't orphan - // already-applied side effects (responseAttachmentSent, finalizeHook) from others. - let sessionStartCompleted = false - for (const s of settled) { - if (s.status !== 'fulfilled') { - logForDebugging( - `Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`, - { level: 'error' }, - ) - continue - } - const r = s.value - if (r.type === 'remove') { - pendingHooks.delete(r.processId) - } else if (r.type === 'response') { - responses.push(r.payload) - pendingHooks.delete(r.processId) - if (r.isSessionStart) sessionStartCompleted = true - } - } - - if (sessionStartCompleted) { - logForDebugging( - `Invalidating session env cache after SessionStart hook completed`, - ) - invalidateSessionEnvCache() - } - - logForDebugging( - `Hooks: checkForNewResponses returning ${responses.length} responses`, - ) - return responses -} - -export function removeDeliveredAsyncHooks(processIds: string[]): void { - for (const processId of processIds) { - const hook = pendingHooks.get(processId) - if (hook && hook.responseAttachmentSent) { - logForDebugging(`Hooks: Removing delivered hook ${processId}`) - hook.stopProgressInterval() - pendingHooks.delete(processId) - } - } -} - -export async function finalizePendingAsyncHooks(): Promise { - const hooks = Array.from(pendingHooks.values()) - await Promise.all( - hooks.map(async hook => { - if (hook.shellCommand?.status === 'completed') { - const result = await hook.shellCommand.result - await finalizeHook( - hook, - result.code, - result.code === 0 ? 'success' : 'error', - ) - } else { - if (hook.shellCommand && hook.shellCommand.status !== 'killed') { - hook.shellCommand.kill() - } - await finalizeHook(hook, 1, 'cancelled') - } - }), - ) - pendingHooks.clear() -} - -// Test utility function to clear all hooks -export function clearAllAsyncHooks(): void { - for (const hook of pendingHooks.values()) { - hook.stopProgressInterval() - } - pendingHooks.clear() -} - - -======================================== -FILE: src_utils_hooks_postSamplingHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\postSamplingHooks.ts -LINES: 70 -========== -import type { QuerySource } from '../../constants/querySource.js' -import type { ToolUseContext } from '../../Tool.js' -import type { Message } from '../../types/message.js' -import { toError } from '../errors.js' -import { logError } from '../log.js' -import type { SystemPrompt } from '../systemPromptType.js' - -// Post-sampling hook - not exposed in settings.json config (yet), only used programmatically - -// Generic context for REPL hooks (both post-sampling and stop hooks) -export type REPLHookContext = { - messages: Message[] // Full message history including assistant responses - systemPrompt: SystemPrompt - userContext: { [k: string]: string } - systemContext: { [k: string]: string } - toolUseContext: ToolUseContext - querySource?: QuerySource -} - -export type PostSamplingHook = ( - context: REPLHookContext, -) => Promise | void - -// Internal registry for post-sampling hooks -const postSamplingHooks: PostSamplingHook[] = [] - -/** - * Register a post-sampling hook that will be called after model sampling completes - * This is an internal API not exposed through settings - */ -export function registerPostSamplingHook(hook: PostSamplingHook): void { - postSamplingHooks.push(hook) -} - -/** - * Clear all registered post-sampling hooks (for testing) - */ -export function clearPostSamplingHooks(): void { - postSamplingHooks.length = 0 -} - -/** - * Execute all registered post-sampling hooks - */ -export async function executePostSamplingHooks( - messages: Message[], - systemPrompt: SystemPrompt, - userContext: { [k: string]: string }, - systemContext: { [k: string]: string }, - toolUseContext: ToolUseContext, - querySource?: QuerySource, -): Promise { - const context: REPLHookContext = { - messages, - systemPrompt, - userContext, - systemContext, - toolUseContext, - querySource, - } - - for (const hook of postSamplingHooks) { - try { - await hook(context) - } catch (error) { - // Log but don't fail on hook errors - logError(toError(error)) - } - } -} - - -======================================== -FILE: src_services_tools_toolHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\services\tools\toolHooks.ts -LINES: 652 -========== -import { - type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - logEvent, -} from 'src/services/analytics/index.js' -import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' -import type z from 'zod/v4' -import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' -import type { HookProgress } from '../../types/hooks.js' -import type { - AssistantMessage, - AttachmentMessage, - ProgressMessage, -} from '../../types/message.js' -import type { PermissionDecision } from '../../types/permissions.js' -import { createAttachmentMessage } from '../../utils/attachments.js' -import { logForDebugging } from '../../utils/debug.js' -import { - executePostToolHooks, - executePostToolUseFailureHooks, - executePreToolHooks, - getPreToolHookBlockingMessage, -} from '../../utils/hooks.js' -import { logError } from '../../utils/log.js' -import { - getRuleBehaviorDescription, - type PermissionDecisionReason, - type PermissionResult, -} from '../../utils/permissions/PermissionResult.js' -import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' -import { formatError } from '../../utils/toolErrors.js' -import { isMcpTool } from '../mcp/utils.js' -import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' - -export type PostToolUseHooksResult = - | MessageUpdateLazy> - | { updatedMCPToolOutput: Output } - -export async function* runPostToolUseHooks( - toolUseContext: ToolUseContext, - tool: Tool, - toolUseID: string, - messageId: string, - toolInput: Record, - toolResponse: Output, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator> { - const postToolStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - let toolOutput = toolResponse - for await (const result of executePostToolHooks( - tool.name, - toolUseID, - toolInput, - toolOutput, - toolUseContext, - permissionMode, - toolUseContext.abortController.signal, - )) { - try { - // Check if we were aborted during hook execution - // IMPORTANT: We emit a cancelled event per hook - if ( - result.message?.type === 'attachment' && - result.message.attachment.type === 'hook_cancelled' - ) { - logEvent('tengu_post_tool_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PostToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PostToolUse', - }), - } - continue - } - - // For JSON {decision:"block"} hooks, executeHooks yields two results: - // {blockingError} and {message: hook_blocking_error attachment}. The - // blockingError path below creates that same attachment, so skip it - // here to avoid displaying the block reason twice (#31301). The - // exit-code-2 path only yields {blockingError}, so it's unaffected. - if ( - result.message && - !( - result.message.type === 'attachment' && - result.message.attachment.type === 'hook_blocking_error' - ) - ) { - yield { message: result.message } - } - - if (result.blockingError) { - yield { - message: createAttachmentMessage({ - type: 'hook_blocking_error', - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - blockingError: result.blockingError, - }), - } - } - - // If hook indicated to prevent continuation, yield a stop reason message - if (result.preventContinuation) { - yield { - message: createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: - result.stopReason || 'Execution stopped by PostToolUse hook', - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - return - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - } - - // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool - if (result.updatedMCPToolOutput && isMcpTool(tool)) { - toolOutput = result.updatedMCPToolOutput as Output - yield { - updatedMCPToolOutput: toolOutput, - } - } - } catch (error) { - const postToolDurationMs = Date.now() - postToolStartTime - logEvent('tengu_post_tool_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: postToolDurationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(error), - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - } - } - } catch (error) { - logError(error) - } -} - -export async function* runPostToolUseFailureHooks( - toolUseContext: ToolUseContext, - tool: Tool, - toolUseID: string, - messageId: string, - processedInput: z.infer, - error: string, - isInterrupt: boolean | undefined, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator< - MessageUpdateLazy> -> { - const postToolStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - for await (const result of executePostToolUseFailureHooks( - tool.name, - toolUseID, - processedInput, - error, - toolUseContext, - isInterrupt, - permissionMode, - toolUseContext.abortController.signal, - )) { - try { - // Check if we were aborted during hook execution - if ( - result.message?.type === 'attachment' && - result.message.attachment.type === 'hook_cancelled' - ) { - logEvent('tengu_post_tool_failure_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - continue - } - - // Skip hook_blocking_error in result.message 鈥?blockingError path - // below creates the same attachment (see #31301 / PostToolUse above). - if ( - result.message && - !( - result.message.type === 'attachment' && - result.message.attachment.type === 'hook_blocking_error' - ) - ) { - yield { message: result.message } - } - - if (result.blockingError) { - yield { - message: createAttachmentMessage({ - type: 'hook_blocking_error', - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - blockingError: result.blockingError, - }), - } - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - } - } catch (hookError) { - const postToolDurationMs = Date.now() - postToolStartTime - logEvent('tengu_post_tool_failure_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: postToolDurationMs, - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(hookError), - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - } - } - } catch (outerError) { - logError(outerError) - } -} - -/** - * Resolve a PreToolUse hook's permission result into a final PermissionDecision. - * - * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json - * deny rules or non-bypass ask rules 鈥?checkRuleBasedPermissions still applies - * (inc-4788 analog). - * Also handles the requiresUserInteraction/requireCanUseTool guards and the - * 'ask' forceDecision passthrough. - * - * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts - * (REPL inner calls) so the permission semantics stay in lockstep. - */ -export async function resolveHookPermissionDecision( - hookPermissionResult: PermissionResult | undefined, - tool: Tool, - input: Record, - toolUseContext: ToolUseContext, - canUseTool: CanUseToolFn, - assistantMessage: AssistantMessage, - toolUseID: string, -): Promise<{ - decision: PermissionDecision - input: Record -}> { - const requiresInteraction = tool.requiresUserInteraction?.() - const requireCanUseTool = toolUseContext.requireCanUseTool - - if (hookPermissionResult?.behavior === 'allow') { - const hookInput = hookPermissionResult.updatedInput ?? input - - // Hook provided updatedInput for an interactive tool 鈥?the hook IS the - // user interaction (e.g. headless wrapper that collected AskUserQuestion - // answers). Treat as non-interactive for the rule-check path. - const interactionSatisfied = - requiresInteraction && hookPermissionResult.updatedInput !== undefined - - if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { - logForDebugging( - `Hook approved tool use for ${tool.name}, but canUseTool is required`, - ) - return { - decision: await canUseTool( - tool, - hookInput, - toolUseContext, - assistantMessage, - toolUseID, - ), - input: hookInput, - } - } - - // Hook allow skips the interactive prompt, but deny rules and non-bypass - // ask rules still apply. - const ruleCheck = await checkRuleBasedPermissions( - tool, - hookInput, - toolUseContext, - ) - if (ruleCheck === null) { - logForDebugging( - interactionSatisfied - ? `Hook satisfied user interaction for ${tool.name} via updatedInput` - : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, - ) - return { decision: hookPermissionResult, input: hookInput } - } - if (ruleCheck.behavior === 'deny') { - logForDebugging( - `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, - ) - return { decision: ruleCheck, input: hookInput } - } - // ask rule 鈥?dialog required despite hook approval - logForDebugging( - `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, - ) - return { - decision: await canUseTool( - tool, - hookInput, - toolUseContext, - assistantMessage, - toolUseID, - ), - input: hookInput, - } - } - - if (hookPermissionResult?.behavior === 'deny') { - logForDebugging(`Hook denied tool use for ${tool.name}`) - return { decision: hookPermissionResult, input } - } - - // No hook decision or 'ask' 鈥?normal permission flow, possibly with - // forceDecision so the dialog shows the hook's ask message. - const forceDecision = - hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined - const askInput = - hookPermissionResult?.behavior === 'ask' && - hookPermissionResult.updatedInput - ? hookPermissionResult.updatedInput - : input - return { - decision: await canUseTool( - tool, - askInput, - toolUseContext, - assistantMessage, - toolUseID, - forceDecision, - ), - input: askInput, - } -} - -export async function* runPreToolUseHooks( - toolUseContext: ToolUseContext, - tool: Tool, - processedInput: Record, - toolUseID: string, - messageId: string, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator< - | { - type: 'message' - message: MessageUpdateLazy< - AttachmentMessage | ProgressMessage - > - } - | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } - | { type: 'hookUpdatedInput'; updatedInput: Record } - | { type: 'preventContinuation'; shouldPreventContinuation: boolean } - | { type: 'stopReason'; stopReason: string } - | { - type: 'additionalContext' - message: MessageUpdateLazy - } - // stop execution - | { type: 'stop' } -> { - const hookStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - - for await (const result of executePreToolHooks( - tool.name, - toolUseID, - processedInput, - toolUseContext, - appState.toolPermissionContext.mode, - toolUseContext.abortController.signal, - undefined, // timeoutMs - use default - toolUseContext.requestPrompt, - tool.getToolUseSummary?.(processedInput), - )) { - try { - if (result.message) { - yield { type: 'message', message: { message: result.message } } - } - if (result.blockingError) { - const denialMessage = getPreToolHookBlockingMessage( - `PreToolUse:${tool.name}`, - result.blockingError, - ) - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'deny', - message: denialMessage, - decisionReason: { - type: 'hook', - hookName: `PreToolUse:${tool.name}`, - reason: denialMessage, - }, - }, - } - } - // Check if hook wants to prevent continuation - if (result.preventContinuation) { - yield { - type: 'preventContinuation', - shouldPreventContinuation: true, - } - if (result.stopReason) { - yield { type: 'stopReason', stopReason: result.stopReason } - } - } - // Check for hook-defined permission behavior - if (result.permissionBehavior !== undefined) { - logForDebugging( - `Hook result has permissionBehavior=${result.permissionBehavior}`, - ) - const decisionReason: PermissionDecisionReason = { - type: 'hook', - hookName: `PreToolUse:${tool.name}`, - hookSource: result.hookSource, - reason: result.hookPermissionDecisionReason, - } - if (result.permissionBehavior === 'allow') { - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'allow', - updatedInput: result.updatedInput, - decisionReason, - }, - } - } else if (result.permissionBehavior === 'ask') { - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'ask', - updatedInput: result.updatedInput, - message: - result.hookPermissionDecisionReason || - `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, - decisionReason, - }, - } - } else { - // deny - updatedInput is irrelevant since tool won't run - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: result.permissionBehavior, - message: - result.hookPermissionDecisionReason || - `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, - decisionReason, - }, - } - } - } - - // Yield updatedInput for passthrough case (no permission decision) - // This allows hooks to modify input while letting normal permission flow continue - if (result.updatedInput && result.permissionBehavior === undefined) { - yield { - type: 'hookUpdatedInput', - updatedInput: result.updatedInput, - } - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - type: 'additionalContext', - message: { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PreToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - } - - // Check if we were aborted during hook execution - if (toolUseContext.abortController.signal.aborted) { - logEvent('tengu_pre_tool_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - type: 'message', - message: { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PreToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - yield { type: 'stop' } - return - } - } catch (error) { - logError(error) - const durationMs = Date.now() - hookStartTime - logEvent('tengu_pre_tool_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: durationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - type: 'message', - message: { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(error), - hookName: `PreToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - yield { type: 'stop' } - } - } - } catch (error) { - logError(error) - yield { type: 'stop' } - return - } -} - - -======================================== -FILE: src_utils_hooks_hooksConfigSnapshot.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigSnapshot.ts -LINES: 133 -========== -import { resetSdkInitState } from '../../bootstrap/state.js' -import { isRestrictedToPluginOnly } from '../settings/pluginOnlyPolicy.js' -// Import as module object so spyOn works in tests (direct imports bypass spies) -import * as settingsModule from '../settings/settings.js' -import { resetSettingsCache } from '../settings/settingsCache.js' -import type { HooksSettings } from '../settings/types.js' - -let initialHooksConfig: HooksSettings | null = null - -/** - * Get hooks from allowed sources. - * If allowManagedHooksOnly is set in policySettings, only managed hooks are returned. - * If disableAllHooks is set in policySettings, no hooks are returned. - * If disableAllHooks is set in non-managed settings, only managed hooks are returned - * (non-managed settings cannot disable managed hooks). - * Otherwise, returns merged hooks from all sources (backwards compatible). - */ -function getHooksFromAllowedSources(): HooksSettings { - const policySettings = settingsModule.getSettingsForSource('policySettings') - - // If managed settings disables all hooks, return empty - if (policySettings?.disableAllHooks === true) { - return {} - } - - // If allowManagedHooksOnly is set in managed settings, only use managed hooks - if (policySettings?.allowManagedHooksOnly === true) { - return policySettings.hooks ?? {} - } - - // strictPluginOnlyCustomization: block user/project/local settings hooks. - // Plugin hooks (registered channel, hooks.ts:1391) are NOT affected 鈥? - // they're assembled separately and the managedOnly skip there is keyed - // on shouldAllowManagedHooksOnly(), not on this policy. Agent frontmatter - // hooks are gated at REGISTRATION (runAgent.ts:~535) by agent source 鈥? - // plugin/built-in/policySettings agents register normally, user-sourced - // agents skip registration under ["hooks"]. A blanket execution-time - // block here would over-kill plugin agents' hooks. - if (isRestrictedToPluginOnly('hooks')) { - return policySettings?.hooks ?? {} - } - - const mergedSettings = settingsModule.getSettings_DEPRECATED() - - // If disableAllHooks is set in non-managed settings, only managed hooks still run - // (non-managed settings cannot override managed hooks) - if (mergedSettings.disableAllHooks === true) { - return policySettings?.hooks ?? {} - } - - // Otherwise, use all hooks (merged from all sources) - backwards compatible - return mergedSettings.hooks ?? {} -} - -/** - * Check if only managed hooks should run. - * This is true when: - * - policySettings has allowManagedHooksOnly: true, OR - * - disableAllHooks is set in non-managed settings (non-managed settings - * cannot disable managed hooks, so they effectively become managed-only) - */ -export function shouldAllowManagedHooksOnly(): boolean { - const policySettings = settingsModule.getSettingsForSource('policySettings') - if (policySettings?.allowManagedHooksOnly === true) { - return true - } - // If disableAllHooks is set but NOT from managed settings, - // treat as managed-only (non-managed hooks disabled, managed hooks still run) - if ( - settingsModule.getSettings_DEPRECATED().disableAllHooks === true && - policySettings?.disableAllHooks !== true - ) { - return true - } - return false -} - -/** - * Check if all hooks (including managed) should be disabled. - * This is only true when managed/policy settings has disableAllHooks: true. - * When disableAllHooks is set in non-managed settings, managed hooks still run. - */ -export function shouldDisableAllHooksIncludingManaged(): boolean { - return ( - settingsModule.getSettingsForSource('policySettings')?.disableAllHooks === - true - ) -} - -/** - * Capture a snapshot of the current hooks configuration - * This should be called once during application startup - * Respects the allowManagedHooksOnly setting - */ -export function captureHooksConfigSnapshot(): void { - initialHooksConfig = getHooksFromAllowedSources() -} - -/** - * Update the hooks configuration snapshot - * This should be called when hooks are modified through the settings - * Respects the allowManagedHooksOnly setting - */ -export function updateHooksConfigSnapshot(): void { - // Reset the session cache to ensure we read fresh settings from disk. - // Without this, the snapshot could use stale cached settings when the user - // edits settings.json externally and then runs /hooks - the session cache - // may not have been invalidated yet (e.g., if the file watcher's stability - // threshold hasn't elapsed). - resetSettingsCache() - initialHooksConfig = getHooksFromAllowedSources() -} - -/** - * Get the current hooks configuration from snapshot - * Falls back to settings if no snapshot exists - * @returns The hooks configuration - */ -export function getHooksConfigFromSnapshot(): HooksSettings | null { - if (initialHooksConfig === null) { - captureHooksConfigSnapshot() - } - return initialHooksConfig -} - -/** - * Reset the hooks configuration snapshot (useful for testing) - * Also resets SDK init state to prevent test pollution - */ -export function resetHooksConfigSnapshot(): void { - initialHooksConfig = null - resetSdkInitState() -} - - -======================================== -FILE: src_commands_hooks_hooks.tsx -======================================== -FILE: E:\Yuanban\cc-haha-src\src\commands\hooks\hooks.tsx -LINES: 13 -========== -import * as React from 'react'; -import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { getTools } from '../../tools.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -export const call: LocalJSXCommandCall = async (onDone, context) => { - logEvent('tengu_hooks_command', {}); - const appState = context.getAppState(); - const permissionContext = appState.toolPermissionContext; - const toolNames = getTools(permissionContext).map(tool => tool.name); - return ; -}; -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119 - - -======================================== -FILE: src_costHook.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\costHook.ts -LINES: 22 -========== -import { useEffect } from 'react' -import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js' -import { hasConsoleBillingAccess } from './utils/billing.js' -import type { FpsMetrics } from './utils/fpsTracker.js' - -export function useCostSummary( - getFpsMetrics?: () => FpsMetrics | undefined, -): void { - useEffect(() => { - const f = () => { - if (hasConsoleBillingAccess()) { - process.stdout.write('\n' + formatTotalCost() + '\n') - } - - saveCurrentSessionCosts(getFpsMetrics?.()) - } - process.on('exit', f) - return () => { - process.off('exit', f) - } - }, []) -} - - -======================================== -FILE: src_hooks_useDeferredHookMessages.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\hooks\useDeferredHookMessages.ts -LINES: 46 -========== -import { useCallback, useEffect, useRef } from 'react' -import type { HookResultMessage, Message } from '../types/message.js' - -/** - * Manages deferred SessionStart hook messages so the REPL can render - * immediately instead of blocking on hook execution (~500ms). - * - * Hook messages are injected asynchronously when the promise resolves. - * Returns a callback that onSubmit should call before the first API - * request to ensure the model always sees hook context. - */ -export function useDeferredHookMessages( - pendingHookMessages: Promise | undefined, - setMessages: (action: React.SetStateAction) => void, -): () => Promise { - const pendingRef = useRef(pendingHookMessages ?? null) - const resolvedRef = useRef(!pendingHookMessages) - - useEffect(() => { - const promise = pendingRef.current - if (!promise) return - let cancelled = false - promise.then(msgs => { - if (cancelled) return - resolvedRef.current = true - pendingRef.current = null - if (msgs.length > 0) { - setMessages(prev => [...msgs, ...prev]) - } - }) - return () => { - cancelled = true - } - }, [setMessages]) - - return useCallback(async () => { - if (resolvedRef.current || !pendingRef.current) return - const msgs = await pendingRef.current - if (resolvedRef.current) return - resolvedRef.current = true - pendingRef.current = null - if (msgs.length > 0) { - setMessages(prev => [...msgs, ...prev]) - } - }, [setMessages]) -} - - -======================================== -FILE: src_query_stopHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.ts -LINES: 591 -========== -import { feature } from 'bun:bundle' -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' -import { getSessionId } from '../bootstrap/state.js' -import { isExtractModeActive } from '../memdir/paths.js' -import { - type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - logEvent, -} from '../services/analytics/index.js' -import type { ToolUseContext } from '../Tool.js' -import { - ensureThreadGoalHookFromTranscript, - isGoalPromptHookCommand, -} from '../goals/goalState.js' -import type { HookResult } from '../types/hooks.js' -import type { HookProgress } from '../types/hooks.js' -import type { - AssistantMessage, - Message, - RequestStartEvent, - StopHookInfo, - StreamEvent, - TombstoneMessage, - ToolUseSummaryMessage, -} from '../types/message.js' -import { createAttachmentMessage } from '../utils/attachments.js' -import { logForDebugging } from '../utils/debug.js' -import { errorMessage } from '../utils/errors.js' -import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' -import { - executeStopHooks, - executeTaskCompletedHooks, - executeTeammateIdleHooks, - getStopHookMessage, - getTaskCompletedHookMessage, - getTeammateIdleHookMessage, -} from '../utils/hooks.js' -import { - createStopHookSummaryMessage, - createCommandInputMessage, - createSystemMessage, - createUserInterruptionMessage, - createUserMessage, -} from '../utils/messages.js' -import type { SystemPrompt } from '../utils/systemPromptType.js' -import { getTaskListId, listTasks } from '../utils/tasks.js' -import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js' - -/* eslint-disable @typescript-eslint/no-require-imports */ -const extractMemoriesModule = feature('EXTRACT_MEMORIES') - ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) - : null -const jobClassifierModule = feature('TEMPLATES') - ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) - : null - -/* eslint-enable @typescript-eslint/no-require-imports */ - -import type { QuerySource } from '../constants/querySource.js' -import { executeAutoDream } from '../services/autoDream/autoDream.js' -import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' -import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' -import { - createCacheSafeParams, - saveCacheSafeParams, -} from '../utils/forkedAgent.js' - -type StopHookResult = { - blockingErrors: Message[] - preventContinuation: boolean -} - -export function shouldLetGoalPromptHookContinue( - result: Pick, -): boolean { - return Boolean( - result.preventContinuation && - isGoalPromptHookCommand(result.blockingError?.command), - ) -} - -export function formatGoalContinuationStatusOutput(reason: string): string { - const normalizedReason = reason - .replace(/^Prompt hook condition was not met:\s*/i, '') - .replace(/[<>&]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 240) - - return normalizedReason - ? `Goal continuing: ${normalizedReason}` - : 'Goal continuing: more work is required' -} - -export async function* handleStopHooks( - messagesForQuery: Message[], - assistantMessages: AssistantMessage[], - systemPrompt: SystemPrompt, - userContext: { [k: string]: string }, - systemContext: { [k: string]: string }, - toolUseContext: ToolUseContext, - querySource: QuerySource, - stopHookActive?: boolean, -): AsyncGenerator< - | StreamEvent - | RequestStartEvent - | Message - | TombstoneMessage - | ToolUseSummaryMessage, - StopHookResult -> { - const hookStartTime = Date.now() - - const stopHookContext: REPLHookContext = { - messages: [...messagesForQuery, ...assistantMessages], - systemPrompt, - userContext, - systemContext, - toolUseContext, - querySource, - } - // Only save params for main session queries 鈥?subagents must not overwrite. - // Outside the prompt-suggestion gate: the REPL /btw command and the - // side_question SDK control_request both read this snapshot, and neither - // depends on prompt suggestions being enabled. - if (querySource === 'repl_main_thread' || querySource === 'sdk') { - saveCacheSafeParams(createCacheSafeParams(stopHookContext)) - } - - // Template job classification: when running as a dispatched job, classify - // state after each turn. Gate on repl_main_thread so background forks - // (extract-memories, auto-dream) don't pollute the timeline with their own - // assistant messages. Await the classifier so state.json is written before - // the turn returns 鈥?otherwise `claude list` shows stale state for the gap. - // Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the - // require()-gated jobs/ import pattern above; spawn.test.ts asserts the - // string matches. - if ( - feature('TEMPLATES') && - process.env.CLAUDE_JOB_DIR && - querySource.startsWith('repl_main_thread') && - !toolUseContext.agentId - ) { - // Full turn history 鈥?assistantMessages resets each queryLoop iteration, - // so tool calls from earlier iterations (Agent spawn, then summary) need - // messagesForQuery to be visible in the tool-call summary. - const turnAssistantMessages = stopHookContext.messages.filter( - (m): m is AssistantMessage => m.type === 'assistant', - ) - const p = jobClassifierModule! - .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) - .catch(err => { - logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { - level: 'error', - }) - }) - await Promise.race([ - p, - // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit - new Promise(r => setTimeout(r, 60_000).unref()), - ]) - } - // --bare / SIMPLE: skip background bookkeeping (prompt suggestion, - // memory extraction, auto-dream). Scripted -p calls don't want auto-memory - // or forked agents contending for resources during shutdown. - if (!isBareMode()) { - // Inline env check for dead code elimination in external builds - if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { - void executePromptSuggestion(stopHookContext) - } - if ( - feature('EXTRACT_MEMORIES') && - !toolUseContext.agentId && - isExtractModeActive() - ) { - // Fire-and-forget in both interactive and non-interactive. For -p/SDK, - // print.ts drains the in-flight promise after flushing the response - // but before gracefulShutdownSync (see drainPendingExtraction). - void extractMemoriesModule!.executeExtractMemories( - stopHookContext, - toolUseContext.appendSystemMessage, - ) - } - if (!toolUseContext.agentId) { - void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) - } - } - - // chicago MCP: auto-unhide + lock release at turn end. - // Main thread only 鈥?the CU lock is a process-wide module-level variable, - // so a subagent's stopHooks releasing it leaves the main thread's cleanup - // seeing isLockHeldLocally()===false 鈫?no exit notification, and unhides - // mid-turn. Subagents don't start CU sessions so this is a pure skip. - if (!toolUseContext.agentId) { - try { - const { cleanupComputerUseAfterTurn } = await import( - '../utils/computerUse/cleanup.js' - ) - await cleanupComputerUseAfterTurn(toolUseContext) - } catch { - // Failures are silent 鈥?this is dogfooding cleanup, not critical path - } - } - - try { - const blockingErrors = [] - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - if (!toolUseContext.agentId) { - ensureThreadGoalHookFromTranscript( - toolUseContext, - getSessionId(), - [...messagesForQuery, ...assistantMessages], - ) - } - - const generator = executeStopHooks( - permissionMode, - toolUseContext.abortController.signal, - undefined, - stopHookActive ?? false, - toolUseContext.agentId, - toolUseContext, - [...messagesForQuery, ...assistantMessages], - toolUseContext.agentType, - ) - - // Consume all progress messages and get blocking errors - let stopHookToolUseID = '' - let hookCount = 0 - let preventedContinuation = false - let stopReason = '' - let hasOutput = false - const hookErrors: string[] = [] - const hookInfos: StopHookInfo[] = [] - let goalCompleted = false - let goalContinuationReason: string | null = null - - // Goal hook's preventContinuation and blockingError arrive as separate - // generator yields 鈥?preventContinuation comes first, but blockingError.command - // (which identifies it as a goal hook) arrives later. Defer the - // preventContinuation decision until we've seen all results and can - // cross-reference with blockingError.command. - let pendingPreventContinuation: { - stopReason: string - toolUseID: string - } | null = null - // Track whether any blockingError came from a goal hook, so we can - // resolve the pending preventContinuation correctly after the loop. - let goalBlockingErrorSeen = false - - for await (const result of generator) { - if (result.message) { - yield result.message - // Track toolUseID from progress messages and count hooks - if (result.message.type === 'progress' && result.message.toolUseID) { - stopHookToolUseID = result.message.toolUseID - hookCount++ - // Extract hook command and prompt text from progress data - const progressData = result.message.data as HookProgress - if (progressData.command) { - hookInfos.push({ - command: progressData.command, - promptText: progressData.promptText, - }) - } - } - // Track errors and output from attachments - if (result.message.type === 'attachment') { - const attachment = result.message.attachment - if ( - 'hookEvent' in attachment && - (attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop') - ) { - if (attachment.type === 'hook_non_blocking_error') { - hookErrors.push( - attachment.stderr || `Exit code ${attachment.exitCode}`, - ) - // Non-blocking errors always have output - hasOutput = true - } else if (attachment.type === 'hook_error_during_execution') { - hookErrors.push(attachment.content) - hasOutput = true - } else if (attachment.type === 'hook_success') { - if (isGoalPromptHookCommand(attachment.command)) { - goalCompleted = true - } - // Check if successful hook produced any stdout/stderr - if ( - (attachment.stdout && attachment.stdout.trim()) || - (attachment.stderr && attachment.stderr.trim()) - ) { - hasOutput = true - } - } - // Extract per-hook duration for timing visibility. - // Hooks run in parallel; match by command + first unassigned entry. - if ('durationMs' in attachment && 'command' in attachment) { - const info = hookInfos.find( - i => - i.command === attachment.command && - i.durationMs === undefined, - ) - if (info) { - info.durationMs = attachment.durationMs - } - } - } - } - } - if (result.blockingError) { - const isGoalHook = isGoalPromptHookCommand(result.blockingError.command) - if (isGoalHook) { - goalContinuationReason ??= result.blockingError.blockingError - // If this blockingError is from a goal hook AND we have a pending - // preventContinuation, the goal hook's intent is block-and-continue - // (not prevent-and-stop). Mark it so we can resolve after the loop. - if (pendingPreventContinuation) { - goalBlockingErrorSeen = true - } - } - const userMessage = createUserMessage({ - content: getStopHookMessage(result.blockingError), - isMeta: true, // Hide from UI (shown in summary message instead) - }) - blockingErrors.push(userMessage) - yield userMessage - hasOutput = true - // Add to hookErrors so it appears in the summary - hookErrors.push(result.blockingError.blockingError) - } - // Check if hook wants to prevent continuation - if (result.preventContinuation) { - // If blockingError.command is already available in this same result, - // we can decide immediately using shouldLetGoalPromptHookContinue. - if (shouldLetGoalPromptHookContinue(result)) { - // Goal hook wants to block-and-continue 鈥?don't prevent. - // The blockingError (in this or a later yield) drives loop continuation. - } else if (result.blockingError?.command) { - // Has a blockingError.command but it's NOT a goal hook 鈫?prevent immediately - preventedContinuation = true - stopReason = result.stopReason || 'Stop hook prevented continuation' - // Create attachment to track the stopped continuation (for structured data) - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: stopReason, - hookName: 'Stop', - toolUseID: stopHookToolUseID, - hookEvent: 'Stop', - }) - } else { - // No blockingError yet 鈥?defer the decision. The goal hook's - // blockingError.command may arrive in a later yield, and we need - // it to distinguish goal-hook preventContinuation (block-and-continue) - // from regular preventContinuation (prevent-and-stop). - pendingPreventContinuation = { - stopReason: result.stopReason || 'Stop hook prevented continuation', - toolUseID: stopHookToolUseID, - } - } - } - - // Check if we were aborted during hook execution - if (toolUseContext.abortController.signal.aborted) { - logEvent('tengu_pre_stop_hooks_cancelled', { - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield createUserInterruptionMessage({ - toolUse: false, - }) - return { blockingErrors: [], preventContinuation: true } - } - } - - // Resolve any pending preventContinuation after collecting all results. - // If a goal hook's blockingError was seen, the preventContinuation was - // the goal hook's signal 鈥?it wants to block-and-continue, not stop. - // The blockingError will drive the query loop continuation via the - // blockingErrors return path. - if (pendingPreventContinuation && goalBlockingErrorSeen) { - // Don't set preventedContinuation 鈥?the blockingErrors will drive - // loop continuation in query.ts. - pendingPreventContinuation = null - } else if (pendingPreventContinuation) { - // No goal hook blockingError was found 鈫?this preventContinuation - // is from a regular hook 鈫?actually prevent and stop the session. - preventedContinuation = true - stopReason = pendingPreventContinuation.stopReason - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: stopReason, - hookName: 'Stop', - toolUseID: pendingPreventContinuation.toolUseID, - hookEvent: 'Stop', - }) - pendingPreventContinuation = null - } - - // Create summary system message if hooks ran - if (hookCount > 0) { - yield createStopHookSummaryMessage( - hookCount, - hookInfos, - hookErrors, - preventedContinuation, - stopReason, - hasOutput, - 'suggestion', - stopHookToolUseID, - ) - - // Send notification about errors (shown in verbose/transcript mode via ctrl+o) - if (hookErrors.length > 0) { - const expandShortcut = getShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) - toolUseContext.addNotification?.({ - key: 'stop-hook-error', - text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, - priority: 'immediate', - }) - } - } - - if (goalCompleted) { - yield createCommandInputMessage( - 'Goal marked complete.', - ) - } - - if (goalContinuationReason) { - yield createCommandInputMessage( - `${formatGoalContinuationStatusOutput(goalContinuationReason)}`, - ) - } - - if (preventedContinuation) { - return { blockingErrors: [], preventContinuation: true } - } - - // Collect blocking errors from stop hooks - if (blockingErrors.length > 0) { - return { blockingErrors, preventContinuation: false } - } - - // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate - if (isTeammate()) { - const teammateName = getAgentName() ?? '' - const teamName = getTeamName() ?? '' - const teammateBlockingErrors: Message[] = [] - let teammatePreventedContinuation = false - let teammateStopReason: string | undefined - // Each hook executor generates its own toolUseID 鈥?capture from progress - // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. - let teammateHookToolUseID = '' - - // Run TaskCompleted hooks for any in-progress tasks owned by this teammate - const taskListId = getTaskListId() - const tasks = await listTasks(taskListId) - const inProgressTasks = tasks.filter( - t => t.status === 'in_progress' && t.owner === teammateName, - ) - - for (const task of inProgressTasks) { - const taskCompletedGenerator = executeTaskCompletedHooks( - task.id, - task.subject, - task.description, - teammateName, - teamName, - permissionMode, - toolUseContext.abortController.signal, - undefined, - toolUseContext, - ) - - for await (const result of taskCompletedGenerator) { - if (result.message) { - if ( - result.message.type === 'progress' && - result.message.toolUseID - ) { - teammateHookToolUseID = result.message.toolUseID - } - yield result.message - } - if (result.blockingError) { - const userMessage = createUserMessage({ - content: getTaskCompletedHookMessage(result.blockingError), - isMeta: true, - }) - teammateBlockingErrors.push(userMessage) - yield userMessage - } - // Match Stop hook behavior: allow preventContinuation/stopReason - if (result.preventContinuation) { - teammatePreventedContinuation = true - teammateStopReason = - result.stopReason || 'TaskCompleted hook prevented continuation' - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: teammateStopReason, - hookName: 'TaskCompleted', - toolUseID: teammateHookToolUseID, - hookEvent: 'TaskCompleted', - }) - } - if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } - } - } - } - - // Run TeammateIdle hooks - const teammateIdleGenerator = executeTeammateIdleHooks( - teammateName, - teamName, - permissionMode, - toolUseContext.abortController.signal, - ) - - for await (const result of teammateIdleGenerator) { - if (result.message) { - if (result.message.type === 'progress' && result.message.toolUseID) { - teammateHookToolUseID = result.message.toolUseID - } - yield result.message - } - if (result.blockingError) { - const userMessage = createUserMessage({ - content: getTeammateIdleHookMessage(result.blockingError), - isMeta: true, - }) - teammateBlockingErrors.push(userMessage) - yield userMessage - } - // Match Stop hook behavior: allow preventContinuation/stopReason - if (result.preventContinuation) { - teammatePreventedContinuation = true - teammateStopReason = - result.stopReason || 'TeammateIdle hook prevented continuation' - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: teammateStopReason, - hookName: 'TeammateIdle', - toolUseID: teammateHookToolUseID, - hookEvent: 'TeammateIdle', - }) - } - if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } - } - } - - if (teammatePreventedContinuation) { - return { blockingErrors: [], preventContinuation: true } - } - - if (teammateBlockingErrors.length > 0) { - return { - blockingErrors: teammateBlockingErrors, - preventContinuation: false, - } - } - } - - return { blockingErrors: [], preventContinuation: false } - } catch (error) { - const durationMs = Date.now() - hookStartTime - logEvent('tengu_stop_hook_error', { - duration: durationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - // Yield a system message that is not visible to the model for the user - // to debug their hook. - yield createSystemMessage( - `Stop hook failed: ${errorMessage(error)}`, - 'warning', - ) - return { blockingErrors: [], preventContinuation: false } - } -} - - -======================================== -FILE: src_utils_hooks_hooksSettings.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksSettings.ts -LINES: 271 -========== -import { resolve } from 'path' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { getSessionId } from '../../bootstrap/state.js' -import type { AppState } from '../../state/AppState.js' -import type { EditableSettingSource } from '../settings/constants.js' -import { SOURCES } from '../settings/constants.js' -import { - getSettingsFilePathForSource, - getSettingsForSource, -} from '../settings/settings.js' -import type { HookCommand, HookMatcher } from '../settings/types.js' -import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' -import { getSessionHooks } from './sessionHooks.js' - -export type HookSource = - | EditableSettingSource - | 'policySettings' - | 'pluginHook' - | 'sessionHook' - | 'builtinHook' - -export interface IndividualHookConfig { - event: HookEvent - config: HookCommand - matcher?: string - source: HookSource - pluginName?: string -} - -/** - * Check if two hooks are equal (comparing only command/prompt content, not timeout) - */ -export function isHookEqual( - a: HookCommand | { type: 'function'; timeout?: number }, - b: HookCommand | { type: 'function'; timeout?: number }, -): boolean { - if (a.type !== b.type) return false - - // Use switch for exhaustive type checking - // Note: We only compare command/prompt content, not timeout - // `if` is part of identity: same command with different `if` conditions - // are distinct hooks (e.g., setup.sh if=Bash(git *) vs if=Bash(npm *)). - const sameIf = (x: { if?: string }, y: { if?: string }) => - (x.if ?? '') === (y.if ?? '') - switch (a.type) { - case 'command': - // shell is part of identity: same command string with different - // shells are distinct hooks. Default 'bash' so undefined === 'bash'. - return ( - b.type === 'command' && - a.command === b.command && - (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) && - sameIf(a, b) - ) - case 'prompt': - return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b) - case 'agent': - return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b) - case 'http': - return b.type === 'http' && a.url === b.url && sameIf(a, b) - case 'function': - // Function hooks can't be compared (no stable identifier) - return false - } -} - -/** Get the display text for a hook */ -export function getHookDisplayText( - hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string }, -): string { - // Return custom status message if provided - if ('statusMessage' in hook && hook.statusMessage) { - return hook.statusMessage - } - - switch (hook.type) { - case 'command': - return hook.command - case 'prompt': - return hook.prompt - case 'agent': - return hook.prompt - case 'http': - return hook.url - case 'callback': - return 'callback' - case 'function': - return 'function' - } -} - -export function getAllHooks(appState: AppState): IndividualHookConfig[] { - const hooks: IndividualHookConfig[] = [] - - // Check if restricted to managed hooks only - const policySettings = getSettingsForSource('policySettings') - const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true - - // If allowManagedHooksOnly is set, don't show any hooks in the UI - // (user/project/local are blocked, and managed hooks are intentionally hidden) - if (!restrictedToManagedOnly) { - // Get hooks from all editable sources - const sources = [ - 'userSettings', - 'projectSettings', - 'localSettings', - ] as EditableSettingSource[] - - // Track which settings files we've already processed to avoid duplicates - // (e.g., when running from home directory, userSettings and projectSettings - // both resolve to ~/.claude/settings.json) - const seenFiles = new Set() - - for (const source of sources) { - const filePath = getSettingsFilePathForSource(source) - if (filePath) { - const resolvedPath = resolve(filePath) - if (seenFiles.has(resolvedPath)) { - continue - } - seenFiles.add(resolvedPath) - } - - const sourceSettings = getSettingsForSource(source) - if (!sourceSettings?.hooks) { - continue - } - - for (const [event, matchers] of Object.entries(sourceSettings.hooks)) { - for (const matcher of matchers as HookMatcher[]) { - for (const hookCommand of matcher.hooks) { - hooks.push({ - event: event as HookEvent, - config: hookCommand, - matcher: matcher.matcher, - source, - }) - } - } - } - } - } - - // Get session hooks - const sessionId = getSessionId() - const sessionHooks = getSessionHooks(appState, sessionId) - for (const [event, matchers] of sessionHooks.entries()) { - for (const matcher of matchers) { - for (const hookCommand of matcher.hooks) { - hooks.push({ - event, - config: hookCommand, - matcher: matcher.matcher, - source: 'sessionHook', - }) - } - } - } - - return hooks -} - -export function getHooksForEvent( - appState: AppState, - event: HookEvent, -): IndividualHookConfig[] { - return getAllHooks(appState).filter(hook => hook.event === event) -} - -export function hookSourceDescriptionDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User settings (~/.claude/settings.json)' - case 'projectSettings': - return 'Project settings (.claude/settings.json)' - case 'localSettings': - return 'Local settings (.claude/settings.local.json)' - case 'pluginHook': - // TODO: Get the actual plugin hook file paths instead of using glob pattern - // We should capture the specific plugin paths during hook registration and display them here - // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" - return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' - case 'sessionHook': - return 'Session hooks (in-memory, temporary)' - case 'builtinHook': - return 'Built-in hooks (registered internally by Claude Code)' - default: - return source as string - } -} - -export function hookSourceHeaderDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User Settings' - case 'projectSettings': - return 'Project Settings' - case 'localSettings': - return 'Local Settings' - case 'pluginHook': - return 'Plugin Hooks' - case 'sessionHook': - return 'Session Hooks' - case 'builtinHook': - return 'Built-in Hooks' - default: - return source as string - } -} - -export function hookSourceInlineDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User' - case 'projectSettings': - return 'Project' - case 'localSettings': - return 'Local' - case 'pluginHook': - return 'Plugin' - case 'sessionHook': - return 'Session' - case 'builtinHook': - return 'Built-in' - default: - return source as string - } -} - -export function sortMatchersByPriority( - matchers: string[], - hooksByEventAndMatcher: Record< - string, - Record - >, - selectedEvent: HookEvent, -): string[] { - // Create a priority map based on SOURCES order (lower index = higher priority) - const sourcePriority = SOURCES.reduce( - (acc, source, index) => { - acc[source] = index - return acc - }, - {} as Record, - ) - - return [...matchers].sort((a, b) => { - const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || [] - const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || [] - - const aSources = Array.from(new Set(aHooks.map(h => h.source))) - const bSources = Array.from(new Set(bHooks.map(h => h.source))) - - // Sort by highest priority source first (lowest priority number) - // Plugin hooks get lowest priority (highest number) - const getSourcePriority = (source: HookSource) => - source === 'pluginHook' || source === 'builtinHook' - ? 999 - : sourcePriority[source as EditableSettingSource] - - const aHighestPriority = Math.min(...aSources.map(getSourcePriority)) - const bHighestPriority = Math.min(...bSources.map(getSourcePriority)) - - if (aHighestPriority !== bHighestPriority) { - return aHighestPriority - bHighestPriority - } - - // If same priority, sort by matcher name - return a.localeCompare(b) - }) -} - - -======================================== -FILE: src_utils_hooks_registerFrontmatterHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerFrontmatterHooks.ts -LINES: 67 -========== -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import { logForDebugging } from '../debug.js' -import type { HooksSettings } from '../settings/types.js' -import { addSessionHook } from './sessionHooks.js' - -/** - * Register hooks from frontmatter (agent or skill) into session-scoped hooks. - * These hooks will be active for the duration of the session/agent and cleaned up - * when the session/agent ends. - * - * @param setAppState Function to update app state - * @param sessionId Session ID to scope the hooks (agent ID for agents, session ID for skills) - * @param hooks The hooks settings from frontmatter - * @param sourceName Human-readable source name for logging (e.g., "agent 'my-agent'") - * @param isAgent If true, converts Stop hooks to SubagentStop (since subagents trigger SubagentStop, not Stop) - */ -export function registerFrontmatterHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - hooks: HooksSettings, - sourceName: string, - isAgent: boolean = false, -): void { - if (!hooks || Object.keys(hooks).length === 0) { - return - } - - let hookCount = 0 - - for (const event of HOOK_EVENTS) { - const matchers = hooks[event] - if (!matchers || matchers.length === 0) { - continue - } - - // For agents, convert Stop hooks to SubagentStop since that's what fires when an agent completes - // (executeStopHooks uses SubagentStop when called with an agentId) - let targetEvent: HookEvent = event - if (isAgent && event === 'Stop') { - targetEvent = 'SubagentStop' - logForDebugging( - `Converting Stop hook to SubagentStop for ${sourceName} (subagents trigger SubagentStop)`, - ) - } - - for (const matcherConfig of matchers) { - const matcher = matcherConfig.matcher ?? '' - const hooksArray = matcherConfig.hooks - - if (!hooksArray || hooksArray.length === 0) { - continue - } - - for (const hook of hooksArray) { - addSessionHook(setAppState, sessionId, targetEvent, matcher, hook) - hookCount++ - } - } - } - - if (hookCount > 0) { - logForDebugging( - `Registered ${hookCount} frontmatter hook(s) from ${sourceName} for session ${sessionId}`, - ) - } -} - - -======================================== -FILE: src_utils_hooks_registerSkillHooks.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerSkillHooks.ts -LINES: 64 -========== -import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import { logForDebugging } from '../debug.js' -import type { HooksSettings } from '../settings/types.js' -import { addSessionHook, removeSessionHook } from './sessionHooks.js' - -/** - * Registers hooks from a skill's frontmatter as session hooks. - * - * Hooks are registered as session-scoped hooks that persist for the duration - * of the session. If a hook has `once: true`, it will be automatically removed - * after its first successful execution. - * - * @param setAppState - Function to update the app state - * @param sessionId - The current session ID - * @param hooks - The hooks settings from the skill's frontmatter - * @param skillName - The name of the skill (for logging) - * @param skillRoot - The base directory of the skill (for CLAUDE_PLUGIN_ROOT env var) - */ -export function registerSkillHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - hooks: HooksSettings, - skillName: string, - skillRoot?: string, -): void { - let registeredCount = 0 - - for (const eventName of HOOK_EVENTS) { - const matchers = hooks[eventName] - if (!matchers) continue - - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - // For once: true hooks, use onHookSuccess callback to remove after execution - const onHookSuccess = hook.once - ? () => { - logForDebugging( - `Removing one-shot hook for event ${eventName} in skill '${skillName}'`, - ) - removeSessionHook(setAppState, sessionId, eventName, hook) - } - : undefined - - addSessionHook( - setAppState, - sessionId, - eventName, - matcher.matcher || '', - hook, - onHookSuccess, - skillRoot, - ) - registeredCount++ - } - } - } - - if (registeredCount > 0) { - logForDebugging( - `Registered ${registeredCount} hooks from skill '${skillName}'`, - ) - } -} - - -======================================== -FILE: src_utils_hooks_apiQueryHookHelper.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\apiQueryHookHelper.ts -LINES: 141 -========== -import { randomUUID } from 'crypto' -import type { QuerySource } from '../../constants/querySource.js' -import { queryModelWithoutStreaming } from '../../services/api/claude.js' -import type { Message } from '../../types/message.js' -import { createAbortController } from '../../utils/abortController.js' -import { logError } from '../../utils/log.js' -import { toError } from '../errors.js' -import { extractTextContent } from '../messages.js' -import { asSystemPrompt } from '../systemPromptType.js' -import type { REPLHookContext } from './postSamplingHooks.js' - -export type ApiQueryHookContext = REPLHookContext & { - queryMessageCount?: number -} - -export type ApiQueryHookConfig = { - name: QuerySource - shouldRun: (context: ApiQueryHookContext) => Promise - - // Build the complete message list to send to the API - buildMessages: (context: ApiQueryHookContext) => Message[] - - // Optional: override system prompt (defaults to context.systemPrompt) - systemPrompt?: string - - // Optional: whether to use tools from context (defaults to true) - // Set to false to pass empty tools array - useTools?: boolean - - parseResponse: (content: string, context: ApiQueryHookContext) => TResult - logResult: ( - result: ApiQueryResult, - context: ApiQueryHookContext, - ) => void - // Must be a function to ensure lazy loading (config is accessed before allowed) - // Receives context so callers can inherit the main loop model if desired. - getModel: (context: ApiQueryHookContext) => string -} - -export type ApiQueryResult = - | { - type: 'success' - queryName: string - result: TResult - messageId: string - model: string - uuid: string - } - | { - type: 'error' - queryName: string - error: Error - uuid: string - } - -export function createApiQueryHook( - config: ApiQueryHookConfig, -) { - return async (context: ApiQueryHookContext): Promise => { - try { - const shouldRun = await config.shouldRun(context) - if (!shouldRun) { - return - } - - const uuid = randomUUID() - - // Build messages using the config's buildMessages function - const messages = config.buildMessages(context) - context.queryMessageCount = messages.length - - // Use config's system prompt if provided, otherwise use context's - const systemPrompt = config.systemPrompt - ? asSystemPrompt([config.systemPrompt]) - : context.systemPrompt - - // Use config's tools preference (defaults to true = use context tools) - const useTools = config.useTools ?? true - const tools = useTools ? context.toolUseContext.options.tools : [] - - // Get model (lazy loaded) - const model = config.getModel(context) - - // Make API call - const response = await queryModelWithoutStreaming({ - messages, - systemPrompt, - thinkingConfig: { type: 'disabled' as const }, - tools, - signal: createAbortController().signal, - options: { - getToolPermissionContext: async () => { - const appState = context.toolUseContext.getAppState() - return appState.toolPermissionContext - }, - model, - toolChoice: undefined, - isNonInteractiveSession: - context.toolUseContext.options.isNonInteractiveSession, - hasAppendSystemPrompt: - !!context.toolUseContext.options.appendSystemPrompt, - temperatureOverride: 0, - agents: context.toolUseContext.options.agentDefinitions.activeAgents, - querySource: config.name, - mcpTools: [], - agentId: context.toolUseContext.agentId, - }, - }) - - // Parse response - const content = extractTextContent(response.message.content).trim() - - try { - const result = config.parseResponse(content, context) - config.logResult( - { - type: 'success', - queryName: config.name, - result, - messageId: response.message.id, - model, - uuid, - }, - context, - ) - } catch (error) { - config.logResult( - { - type: 'error', - queryName: config.name, - error: error as Error, - uuid, - }, - context, - ) - } - } catch (error) { - logError(toError(error)) - } - } -} - - -======================================== -FILE: src_utils_hooks_fileChangedWatcher.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\fileChangedWatcher.ts -LINES: 191 -========== -import chokidar, { type FSWatcher } from 'chokidar' -import { isAbsolute, join } from 'path' -import { registerCleanup } from '../cleanupRegistry.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import { - executeCwdChangedHooks, - executeFileChangedHooks, - type HookOutsideReplResult, -} from '../hooks.js' -import { clearCwdEnvFiles } from '../sessionEnvironment.js' -import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js' - -let watcher: FSWatcher | null = null -let currentCwd: string -let dynamicWatchPaths: string[] = [] -let dynamicWatchPathsSorted: string[] = [] -let initialized = false -let hasEnvHooks = false -let notifyCallback: ((text: string, isError: boolean) => void) | null = null - -export function setEnvHookNotifier( - cb: ((text: string, isError: boolean) => void) | null, -): void { - notifyCallback = cb -} - -export function initializeFileChangedWatcher(cwd: string): void { - if (initialized) return - initialized = true - currentCwd = cwd - - const config = getHooksConfigFromSnapshot() - hasEnvHooks = - (config?.CwdChanged?.length ?? 0) > 0 || - (config?.FileChanged?.length ?? 0) > 0 - - if (hasEnvHooks) { - registerCleanup(async () => dispose()) - } - - const paths = resolveWatchPaths(config) - if (paths.length === 0) return - - startWatching(paths) -} - -function resolveWatchPaths( - config?: ReturnType, -): string[] { - const matchers = (config ?? getHooksConfigFromSnapshot())?.FileChanged ?? [] - - // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env") - const staticPaths: string[] = [] - for (const m of matchers) { - if (!m.matcher) continue - for (const name of m.matcher.split('|').map(s => s.trim())) { - if (!name) continue - staticPaths.push(isAbsolute(name) ? name : join(currentCwd, name)) - } - } - - // Combine static matcher paths with dynamic paths from hook output - return [...new Set([...staticPaths, ...dynamicWatchPaths])] -} - -function startWatching(paths: string[]): void { - logForDebugging(`FileChanged: watching ${paths.length} paths`) - watcher = chokidar.watch(paths, { - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 200 }, - ignorePermissionErrors: true, - }) - watcher.on('change', p => handleFileEvent(p, 'change')) - watcher.on('add', p => handleFileEvent(p, 'add')) - watcher.on('unlink', p => handleFileEvent(p, 'unlink')) -} - -function handleFileEvent( - path: string, - event: 'change' | 'add' | 'unlink', -): void { - logForDebugging(`FileChanged: ${event} ${path}`) - void executeFileChangedHooks(path, event) - .then(({ results, watchPaths, systemMessages }) => { - if (watchPaths.length > 0) { - updateWatchPaths(watchPaths) - } - for (const msg of systemMessages) { - notifyCallback?.(msg, false) - } - for (const r of results) { - if (!r.succeeded && r.output) { - notifyCallback?.(r.output, true) - } - } - }) - .catch(e => { - const msg = errorMessage(e) - logForDebugging(`FileChanged hook failed: ${msg}`, { - level: 'error', - }) - notifyCallback?.(msg, true) - }) -} - -export function updateWatchPaths(paths: string[]): void { - if (!initialized) return - const sorted = paths.slice().sort() - if ( - sorted.length === dynamicWatchPathsSorted.length && - sorted.every((p, i) => p === dynamicWatchPathsSorted[i]) - ) { - return - } - dynamicWatchPaths = paths - dynamicWatchPathsSorted = sorted - restartWatching() -} - -function restartWatching(): void { - if (watcher) { - void watcher.close() - watcher = null - } - const paths = resolveWatchPaths() - if (paths.length > 0) { - startWatching(paths) - } -} - -export async function onCwdChangedForHooks( - oldCwd: string, - newCwd: string, -): Promise { - if (oldCwd === newCwd) return - - // Re-evaluate from the current snapshot so mid-session hook changes are picked up - const config = getHooksConfigFromSnapshot() - const currentHasEnvHooks = - (config?.CwdChanged?.length ?? 0) > 0 || - (config?.FileChanged?.length ?? 0) > 0 - if (!currentHasEnvHooks) return - currentCwd = newCwd - - await clearCwdEnvFiles() - const hookResult = await executeCwdChangedHooks(oldCwd, newCwd).catch(e => { - const msg = errorMessage(e) - logForDebugging(`CwdChanged hook failed: ${msg}`, { - level: 'error', - }) - notifyCallback?.(msg, true) - return { - results: [] as HookOutsideReplResult[], - watchPaths: [] as string[], - systemMessages: [] as string[], - } - }) - dynamicWatchPaths = hookResult.watchPaths - dynamicWatchPathsSorted = hookResult.watchPaths.slice().sort() - for (const msg of hookResult.systemMessages) { - notifyCallback?.(msg, false) - } - for (const r of hookResult.results) { - if (!r.succeeded && r.output) { - notifyCallback?.(r.output, true) - } - } - - // Re-resolve matcher paths against the new cwd - if (initialized) { - restartWatching() - } -} - -function dispose(): void { - if (watcher) { - void watcher.close() - watcher = null - } - dynamicWatchPaths = [] - dynamicWatchPathsSorted = [] - initialized = false - hasEnvHooks = false - notifyCallback = null -} - -export function resetFileChangedWatcherForTesting(): void { - dispose() -} - - -======================================== -FILE: src_query_stopHooks.test.ts -======================================== -FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.test.ts -LINES: 49 -========== -import { describe, expect, test } from 'bun:test' -import { - formatGoalContinuationStatusOutput, - shouldLetGoalPromptHookContinue, -} from './stopHooks.js' - -describe('stop hook goal continuation', () => { - test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => { - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: true, - blockingError: { - blockingError: 'Prompt hook condition was not met: keep working', - command: '\nship the feature', - }, - }), - ).toBe(true) - }) - - test('preserves prevent-continuation semantics for non-goal hooks', () => { - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: true, - blockingError: { - blockingError: 'Prompt hook condition was not met: stop', - command: 'ordinary prompt hook', - }, - }), - ).toBe(false) - - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: false, - blockingError: { - blockingError: 'Prompt hook condition was not met: keep working', - command: '\nship the feature', - }, - }), - ).toBe(false) - }) - - test('formats goal continuation status output for visible transcript separators', () => { - expect( - formatGoalContinuationStatusOutput( - 'Prompt hook condition was not met: finish & verify', - ), - ).toBe('Goal continuing: finish release verify') - }) -}) diff --git a/hook-dump/src_commands_hooks_hooks.tsx b/hook-dump/src_commands_hooks_hooks.tsx deleted file mode 100644 index dc38bbadee..0000000000 --- a/hook-dump/src_commands_hooks_hooks.tsx +++ /dev/null @@ -1,16 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\commands\hooks\hooks.tsx -LINES: 13 -========== -import * as React from 'react'; -import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; -import { logEvent } from '../../services/analytics/index.js'; -import { getTools } from '../../tools.js'; -import type { LocalJSXCommandCall } from '../../types/command.js'; -export const call: LocalJSXCommandCall = async (onDone, context) => { - logEvent('tengu_hooks_command', {}); - const appState = context.getAppState(); - const permissionContext = appState.toolPermissionContext; - const toolNames = getTools(permissionContext).map(tool => tool.name); - return ; -}; -//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkhvb2tzQ29uZmlnTWVudSIsImxvZ0V2ZW50IiwiZ2V0VG9vbHMiLCJMb2NhbEpTWENvbW1hbmRDYWxsIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJhcHBTdGF0ZSIsImdldEFwcFN0YXRlIiwicGVybWlzc2lvbkNvbnRleHQiLCJ0b29sUGVybWlzc2lvbkNvbnRleHQiLCJ0b29sTmFtZXMiLCJtYXAiLCJ0b29sIiwibmFtZSJdLCJzb3VyY2VzIjpbImhvb2tzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IEhvb2tzQ29uZmlnTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvaG9va3MvSG9va3NDb25maWdNZW51LmpzJ1xuaW1wb3J0IHsgbG9nRXZlbnQgfSBmcm9tICcuLi8uLi9zZXJ2aWNlcy9hbmFseXRpY3MvaW5kZXguanMnXG5pbXBvcnQgeyBnZXRUb29scyB9IGZyb20gJy4uLy4uL3Rvb2xzLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRDYWxsIH0gZnJvbSAnLi4vLi4vdHlwZXMvY29tbWFuZC5qcydcblxuZXhwb3J0IGNvbnN0IGNhbGw6IExvY2FsSlNYQ29tbWFuZENhbGwgPSBhc3luYyAob25Eb25lLCBjb250ZXh0KSA9PiB7XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ob29rc19jb21tYW5kJywge30pXG4gIGNvbnN0IGFwcFN0YXRlID0gY29udGV4dC5nZXRBcHBTdGF0ZSgpXG4gIGNvbnN0IHBlcm1pc3Npb25Db250ZXh0ID0gYXBwU3RhdGUudG9vbFBlcm1pc3Npb25Db250ZXh0XG4gIGNvbnN0IHRvb2xOYW1lcyA9IGdldFRvb2xzKHBlcm1pc3Npb25Db250ZXh0KS5tYXAodG9vbCA9PiB0b29sLm5hbWUpXG4gIHJldHVybiA8SG9va3NDb25maWdNZW51IHRvb2xOYW1lcz17dG9vbE5hbWVzfSBvbkV4aXQ9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxlQUFlLFFBQVEsMkNBQTJDO0FBQzNFLFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxjQUFjQyxtQkFBbUIsUUFBUSx3QkFBd0I7QUFFakUsT0FBTyxNQUFNQyxJQUFJLEVBQUVELG1CQUFtQixHQUFHLE1BQUFDLENBQU9DLE1BQU0sRUFBRUMsT0FBTyxLQUFLO0VBQ2xFTCxRQUFRLENBQUMscUJBQXFCLEVBQUUsQ0FBQyxDQUFDLENBQUM7RUFDbkMsTUFBTU0sUUFBUSxHQUFHRCxPQUFPLENBQUNFLFdBQVcsQ0FBQyxDQUFDO0VBQ3RDLE1BQU1DLGlCQUFpQixHQUFHRixRQUFRLENBQUNHLHFCQUFxQjtFQUN4RCxNQUFNQyxTQUFTLEdBQUdULFFBQVEsQ0FBQ08saUJBQWlCLENBQUMsQ0FBQ0csR0FBRyxDQUFDQyxJQUFJLElBQUlBLElBQUksQ0FBQ0MsSUFBSSxDQUFDO0VBQ3BFLE9BQU8sQ0FBQyxlQUFlLENBQUMsU0FBUyxDQUFDLENBQUNILFNBQVMsQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDTixNQUFNLENBQUMsR0FBRztBQUNsRSxDQUFDIiwiaWdub3JlTGlzdCI6W119 diff --git a/hook-dump/src_costHook.ts b/hook-dump/src_costHook.ts deleted file mode 100644 index c4bed8f7d9..0000000000 --- a/hook-dump/src_costHook.ts +++ /dev/null @@ -1,25 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\costHook.ts -LINES: 22 -========== -import { useEffect } from 'react' -import { formatTotalCost, saveCurrentSessionCosts } from './cost-tracker.js' -import { hasConsoleBillingAccess } from './utils/billing.js' -import type { FpsMetrics } from './utils/fpsTracker.js' - -export function useCostSummary( - getFpsMetrics?: () => FpsMetrics | undefined, -): void { - useEffect(() => { - const f = () => { - if (hasConsoleBillingAccess()) { - process.stdout.write('\n' + formatTotalCost() + '\n') - } - - saveCurrentSessionCosts(getFpsMetrics?.()) - } - process.on('exit', f) - return () => { - process.off('exit', f) - } - }, []) -} diff --git a/hook-dump/src_hooks_useDeferredHookMessages.ts b/hook-dump/src_hooks_useDeferredHookMessages.ts deleted file mode 100644 index 6acc6e9d94..0000000000 --- a/hook-dump/src_hooks_useDeferredHookMessages.ts +++ /dev/null @@ -1,49 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\hooks\useDeferredHookMessages.ts -LINES: 46 -========== -import { useCallback, useEffect, useRef } from 'react' -import type { HookResultMessage, Message } from '../types/message.js' - -/** - * Manages deferred SessionStart hook messages so the REPL can render - * immediately instead of blocking on hook execution (~500ms). - * - * Hook messages are injected asynchronously when the promise resolves. - * Returns a callback that onSubmit should call before the first API - * request to ensure the model always sees hook context. - */ -export function useDeferredHookMessages( - pendingHookMessages: Promise | undefined, - setMessages: (action: React.SetStateAction) => void, -): () => Promise { - const pendingRef = useRef(pendingHookMessages ?? null) - const resolvedRef = useRef(!pendingHookMessages) - - useEffect(() => { - const promise = pendingRef.current - if (!promise) return - let cancelled = false - promise.then(msgs => { - if (cancelled) return - resolvedRef.current = true - pendingRef.current = null - if (msgs.length > 0) { - setMessages(prev => [...msgs, ...prev]) - } - }) - return () => { - cancelled = true - } - }, [setMessages]) - - return useCallback(async () => { - if (resolvedRef.current || !pendingRef.current) return - const msgs = await pendingRef.current - if (resolvedRef.current) return - resolvedRef.current = true - pendingRef.current = null - if (msgs.length > 0) { - setMessages(prev => [...msgs, ...prev]) - } - }, [setMessages]) -} diff --git a/hook-dump/src_query_stopHooks.test.ts b/hook-dump/src_query_stopHooks.test.ts deleted file mode 100644 index 277393fbb4..0000000000 --- a/hook-dump/src_query_stopHooks.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.test.ts -LINES: 49 -========== -import { describe, expect, test } from 'bun:test' -import { - formatGoalContinuationStatusOutput, - shouldLetGoalPromptHookContinue, -} from './stopHooks.js' - -describe('stop hook goal continuation', () => { - test('converts unmet managed /goal prompt hooks into normal blocking continuation', () => { - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: true, - blockingError: { - blockingError: 'Prompt hook condition was not met: keep working', - command: '\nship the feature', - }, - }), - ).toBe(true) - }) - - test('preserves prevent-continuation semantics for non-goal hooks', () => { - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: true, - blockingError: { - blockingError: 'Prompt hook condition was not met: stop', - command: 'ordinary prompt hook', - }, - }), - ).toBe(false) - - expect( - shouldLetGoalPromptHookContinue({ - preventContinuation: false, - blockingError: { - blockingError: 'Prompt hook condition was not met: keep working', - command: '\nship the feature', - }, - }), - ).toBe(false) - }) - - test('formats goal continuation status output for visible transcript separators', () => { - expect( - formatGoalContinuationStatusOutput( - 'Prompt hook condition was not met: finish & verify', - ), - ).toBe('Goal continuing: finish release verify') - }) -}) diff --git a/hook-dump/src_query_stopHooks.ts b/hook-dump/src_query_stopHooks.ts deleted file mode 100644 index 691997a320..0000000000 --- a/hook-dump/src_query_stopHooks.ts +++ /dev/null @@ -1,594 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\query\stopHooks.ts -LINES: 591 -========== -import { feature } from 'bun:bundle' -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' -import { getSessionId } from '../bootstrap/state.js' -import { isExtractModeActive } from '../memdir/paths.js' -import { - type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - logEvent, -} from '../services/analytics/index.js' -import type { ToolUseContext } from '../Tool.js' -import { - ensureThreadGoalHookFromTranscript, - isGoalPromptHookCommand, -} from '../goals/goalState.js' -import type { HookResult } from '../types/hooks.js' -import type { HookProgress } from '../types/hooks.js' -import type { - AssistantMessage, - Message, - RequestStartEvent, - StopHookInfo, - StreamEvent, - TombstoneMessage, - ToolUseSummaryMessage, -} from '../types/message.js' -import { createAttachmentMessage } from '../utils/attachments.js' -import { logForDebugging } from '../utils/debug.js' -import { errorMessage } from '../utils/errors.js' -import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' -import { - executeStopHooks, - executeTaskCompletedHooks, - executeTeammateIdleHooks, - getStopHookMessage, - getTaskCompletedHookMessage, - getTeammateIdleHookMessage, -} from '../utils/hooks.js' -import { - createStopHookSummaryMessage, - createCommandInputMessage, - createSystemMessage, - createUserInterruptionMessage, - createUserMessage, -} from '../utils/messages.js' -import type { SystemPrompt } from '../utils/systemPromptType.js' -import { getTaskListId, listTasks } from '../utils/tasks.js' -import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js' - -/* eslint-disable @typescript-eslint/no-require-imports */ -const extractMemoriesModule = feature('EXTRACT_MEMORIES') - ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) - : null -const jobClassifierModule = feature('TEMPLATES') - ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) - : null - -/* eslint-enable @typescript-eslint/no-require-imports */ - -import type { QuerySource } from '../constants/querySource.js' -import { executeAutoDream } from '../services/autoDream/autoDream.js' -import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' -import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' -import { - createCacheSafeParams, - saveCacheSafeParams, -} from '../utils/forkedAgent.js' - -type StopHookResult = { - blockingErrors: Message[] - preventContinuation: boolean -} - -export function shouldLetGoalPromptHookContinue( - result: Pick, -): boolean { - return Boolean( - result.preventContinuation && - isGoalPromptHookCommand(result.blockingError?.command), - ) -} - -export function formatGoalContinuationStatusOutput(reason: string): string { - const normalizedReason = reason - .replace(/^Prompt hook condition was not met:\s*/i, '') - .replace(/[<>&]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - .slice(0, 240) - - return normalizedReason - ? `Goal continuing: ${normalizedReason}` - : 'Goal continuing: more work is required' -} - -export async function* handleStopHooks( - messagesForQuery: Message[], - assistantMessages: AssistantMessage[], - systemPrompt: SystemPrompt, - userContext: { [k: string]: string }, - systemContext: { [k: string]: string }, - toolUseContext: ToolUseContext, - querySource: QuerySource, - stopHookActive?: boolean, -): AsyncGenerator< - | StreamEvent - | RequestStartEvent - | Message - | TombstoneMessage - | ToolUseSummaryMessage, - StopHookResult -> { - const hookStartTime = Date.now() - - const stopHookContext: REPLHookContext = { - messages: [...messagesForQuery, ...assistantMessages], - systemPrompt, - userContext, - systemContext, - toolUseContext, - querySource, - } - // Only save params for main session queries 鈥?subagents must not overwrite. - // Outside the prompt-suggestion gate: the REPL /btw command and the - // side_question SDK control_request both read this snapshot, and neither - // depends on prompt suggestions being enabled. - if (querySource === 'repl_main_thread' || querySource === 'sdk') { - saveCacheSafeParams(createCacheSafeParams(stopHookContext)) - } - - // Template job classification: when running as a dispatched job, classify - // state after each turn. Gate on repl_main_thread so background forks - // (extract-memories, auto-dream) don't pollute the timeline with their own - // assistant messages. Await the classifier so state.json is written before - // the turn returns 鈥?otherwise `claude list` shows stale state for the gap. - // Env key hardcoded (vs importing JOB_ENV_KEY from jobs/state) to match the - // require()-gated jobs/ import pattern above; spawn.test.ts asserts the - // string matches. - if ( - feature('TEMPLATES') && - process.env.CLAUDE_JOB_DIR && - querySource.startsWith('repl_main_thread') && - !toolUseContext.agentId - ) { - // Full turn history 鈥?assistantMessages resets each queryLoop iteration, - // so tool calls from earlier iterations (Agent spawn, then summary) need - // messagesForQuery to be visible in the tool-call summary. - const turnAssistantMessages = stopHookContext.messages.filter( - (m): m is AssistantMessage => m.type === 'assistant', - ) - const p = jobClassifierModule! - .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) - .catch(err => { - logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { - level: 'error', - }) - }) - await Promise.race([ - p, - // eslint-disable-next-line no-restricted-syntax -- sleep() has no .unref(); timer must not block exit - new Promise(r => setTimeout(r, 60_000).unref()), - ]) - } - // --bare / SIMPLE: skip background bookkeeping (prompt suggestion, - // memory extraction, auto-dream). Scripted -p calls don't want auto-memory - // or forked agents contending for resources during shutdown. - if (!isBareMode()) { - // Inline env check for dead code elimination in external builds - if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { - void executePromptSuggestion(stopHookContext) - } - if ( - feature('EXTRACT_MEMORIES') && - !toolUseContext.agentId && - isExtractModeActive() - ) { - // Fire-and-forget in both interactive and non-interactive. For -p/SDK, - // print.ts drains the in-flight promise after flushing the response - // but before gracefulShutdownSync (see drainPendingExtraction). - void extractMemoriesModule!.executeExtractMemories( - stopHookContext, - toolUseContext.appendSystemMessage, - ) - } - if (!toolUseContext.agentId) { - void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) - } - } - - // chicago MCP: auto-unhide + lock release at turn end. - // Main thread only 鈥?the CU lock is a process-wide module-level variable, - // so a subagent's stopHooks releasing it leaves the main thread's cleanup - // seeing isLockHeldLocally()===false 鈫?no exit notification, and unhides - // mid-turn. Subagents don't start CU sessions so this is a pure skip. - if (!toolUseContext.agentId) { - try { - const { cleanupComputerUseAfterTurn } = await import( - '../utils/computerUse/cleanup.js' - ) - await cleanupComputerUseAfterTurn(toolUseContext) - } catch { - // Failures are silent 鈥?this is dogfooding cleanup, not critical path - } - } - - try { - const blockingErrors = [] - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - if (!toolUseContext.agentId) { - ensureThreadGoalHookFromTranscript( - toolUseContext, - getSessionId(), - [...messagesForQuery, ...assistantMessages], - ) - } - - const generator = executeStopHooks( - permissionMode, - toolUseContext.abortController.signal, - undefined, - stopHookActive ?? false, - toolUseContext.agentId, - toolUseContext, - [...messagesForQuery, ...assistantMessages], - toolUseContext.agentType, - ) - - // Consume all progress messages and get blocking errors - let stopHookToolUseID = '' - let hookCount = 0 - let preventedContinuation = false - let stopReason = '' - let hasOutput = false - const hookErrors: string[] = [] - const hookInfos: StopHookInfo[] = [] - let goalCompleted = false - let goalContinuationReason: string | null = null - - // Goal hook's preventContinuation and blockingError arrive as separate - // generator yields 鈥?preventContinuation comes first, but blockingError.command - // (which identifies it as a goal hook) arrives later. Defer the - // preventContinuation decision until we've seen all results and can - // cross-reference with blockingError.command. - let pendingPreventContinuation: { - stopReason: string - toolUseID: string - } | null = null - // Track whether any blockingError came from a goal hook, so we can - // resolve the pending preventContinuation correctly after the loop. - let goalBlockingErrorSeen = false - - for await (const result of generator) { - if (result.message) { - yield result.message - // Track toolUseID from progress messages and count hooks - if (result.message.type === 'progress' && result.message.toolUseID) { - stopHookToolUseID = result.message.toolUseID - hookCount++ - // Extract hook command and prompt text from progress data - const progressData = result.message.data as HookProgress - if (progressData.command) { - hookInfos.push({ - command: progressData.command, - promptText: progressData.promptText, - }) - } - } - // Track errors and output from attachments - if (result.message.type === 'attachment') { - const attachment = result.message.attachment - if ( - 'hookEvent' in attachment && - (attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop') - ) { - if (attachment.type === 'hook_non_blocking_error') { - hookErrors.push( - attachment.stderr || `Exit code ${attachment.exitCode}`, - ) - // Non-blocking errors always have output - hasOutput = true - } else if (attachment.type === 'hook_error_during_execution') { - hookErrors.push(attachment.content) - hasOutput = true - } else if (attachment.type === 'hook_success') { - if (isGoalPromptHookCommand(attachment.command)) { - goalCompleted = true - } - // Check if successful hook produced any stdout/stderr - if ( - (attachment.stdout && attachment.stdout.trim()) || - (attachment.stderr && attachment.stderr.trim()) - ) { - hasOutput = true - } - } - // Extract per-hook duration for timing visibility. - // Hooks run in parallel; match by command + first unassigned entry. - if ('durationMs' in attachment && 'command' in attachment) { - const info = hookInfos.find( - i => - i.command === attachment.command && - i.durationMs === undefined, - ) - if (info) { - info.durationMs = attachment.durationMs - } - } - } - } - } - if (result.blockingError) { - const isGoalHook = isGoalPromptHookCommand(result.blockingError.command) - if (isGoalHook) { - goalContinuationReason ??= result.blockingError.blockingError - // If this blockingError is from a goal hook AND we have a pending - // preventContinuation, the goal hook's intent is block-and-continue - // (not prevent-and-stop). Mark it so we can resolve after the loop. - if (pendingPreventContinuation) { - goalBlockingErrorSeen = true - } - } - const userMessage = createUserMessage({ - content: getStopHookMessage(result.blockingError), - isMeta: true, // Hide from UI (shown in summary message instead) - }) - blockingErrors.push(userMessage) - yield userMessage - hasOutput = true - // Add to hookErrors so it appears in the summary - hookErrors.push(result.blockingError.blockingError) - } - // Check if hook wants to prevent continuation - if (result.preventContinuation) { - // If blockingError.command is already available in this same result, - // we can decide immediately using shouldLetGoalPromptHookContinue. - if (shouldLetGoalPromptHookContinue(result)) { - // Goal hook wants to block-and-continue 鈥?don't prevent. - // The blockingError (in this or a later yield) drives loop continuation. - } else if (result.blockingError?.command) { - // Has a blockingError.command but it's NOT a goal hook 鈫?prevent immediately - preventedContinuation = true - stopReason = result.stopReason || 'Stop hook prevented continuation' - // Create attachment to track the stopped continuation (for structured data) - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: stopReason, - hookName: 'Stop', - toolUseID: stopHookToolUseID, - hookEvent: 'Stop', - }) - } else { - // No blockingError yet 鈥?defer the decision. The goal hook's - // blockingError.command may arrive in a later yield, and we need - // it to distinguish goal-hook preventContinuation (block-and-continue) - // from regular preventContinuation (prevent-and-stop). - pendingPreventContinuation = { - stopReason: result.stopReason || 'Stop hook prevented continuation', - toolUseID: stopHookToolUseID, - } - } - } - - // Check if we were aborted during hook execution - if (toolUseContext.abortController.signal.aborted) { - logEvent('tengu_pre_stop_hooks_cancelled', { - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield createUserInterruptionMessage({ - toolUse: false, - }) - return { blockingErrors: [], preventContinuation: true } - } - } - - // Resolve any pending preventContinuation after collecting all results. - // If a goal hook's blockingError was seen, the preventContinuation was - // the goal hook's signal 鈥?it wants to block-and-continue, not stop. - // The blockingError will drive the query loop continuation via the - // blockingErrors return path. - if (pendingPreventContinuation && goalBlockingErrorSeen) { - // Don't set preventedContinuation 鈥?the blockingErrors will drive - // loop continuation in query.ts. - pendingPreventContinuation = null - } else if (pendingPreventContinuation) { - // No goal hook blockingError was found 鈫?this preventContinuation - // is from a regular hook 鈫?actually prevent and stop the session. - preventedContinuation = true - stopReason = pendingPreventContinuation.stopReason - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: stopReason, - hookName: 'Stop', - toolUseID: pendingPreventContinuation.toolUseID, - hookEvent: 'Stop', - }) - pendingPreventContinuation = null - } - - // Create summary system message if hooks ran - if (hookCount > 0) { - yield createStopHookSummaryMessage( - hookCount, - hookInfos, - hookErrors, - preventedContinuation, - stopReason, - hasOutput, - 'suggestion', - stopHookToolUseID, - ) - - // Send notification about errors (shown in verbose/transcript mode via ctrl+o) - if (hookErrors.length > 0) { - const expandShortcut = getShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) - toolUseContext.addNotification?.({ - key: 'stop-hook-error', - text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, - priority: 'immediate', - }) - } - } - - if (goalCompleted) { - yield createCommandInputMessage( - 'Goal marked complete.', - ) - } - - if (goalContinuationReason) { - yield createCommandInputMessage( - `${formatGoalContinuationStatusOutput(goalContinuationReason)}`, - ) - } - - if (preventedContinuation) { - return { blockingErrors: [], preventContinuation: true } - } - - // Collect blocking errors from stop hooks - if (blockingErrors.length > 0) { - return { blockingErrors, preventContinuation: false } - } - - // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate - if (isTeammate()) { - const teammateName = getAgentName() ?? '' - const teamName = getTeamName() ?? '' - const teammateBlockingErrors: Message[] = [] - let teammatePreventedContinuation = false - let teammateStopReason: string | undefined - // Each hook executor generates its own toolUseID 鈥?capture from progress - // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. - let teammateHookToolUseID = '' - - // Run TaskCompleted hooks for any in-progress tasks owned by this teammate - const taskListId = getTaskListId() - const tasks = await listTasks(taskListId) - const inProgressTasks = tasks.filter( - t => t.status === 'in_progress' && t.owner === teammateName, - ) - - for (const task of inProgressTasks) { - const taskCompletedGenerator = executeTaskCompletedHooks( - task.id, - task.subject, - task.description, - teammateName, - teamName, - permissionMode, - toolUseContext.abortController.signal, - undefined, - toolUseContext, - ) - - for await (const result of taskCompletedGenerator) { - if (result.message) { - if ( - result.message.type === 'progress' && - result.message.toolUseID - ) { - teammateHookToolUseID = result.message.toolUseID - } - yield result.message - } - if (result.blockingError) { - const userMessage = createUserMessage({ - content: getTaskCompletedHookMessage(result.blockingError), - isMeta: true, - }) - teammateBlockingErrors.push(userMessage) - yield userMessage - } - // Match Stop hook behavior: allow preventContinuation/stopReason - if (result.preventContinuation) { - teammatePreventedContinuation = true - teammateStopReason = - result.stopReason || 'TaskCompleted hook prevented continuation' - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: teammateStopReason, - hookName: 'TaskCompleted', - toolUseID: teammateHookToolUseID, - hookEvent: 'TaskCompleted', - }) - } - if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } - } - } - } - - // Run TeammateIdle hooks - const teammateIdleGenerator = executeTeammateIdleHooks( - teammateName, - teamName, - permissionMode, - toolUseContext.abortController.signal, - ) - - for await (const result of teammateIdleGenerator) { - if (result.message) { - if (result.message.type === 'progress' && result.message.toolUseID) { - teammateHookToolUseID = result.message.toolUseID - } - yield result.message - } - if (result.blockingError) { - const userMessage = createUserMessage({ - content: getTeammateIdleHookMessage(result.blockingError), - isMeta: true, - }) - teammateBlockingErrors.push(userMessage) - yield userMessage - } - // Match Stop hook behavior: allow preventContinuation/stopReason - if (result.preventContinuation) { - teammatePreventedContinuation = true - teammateStopReason = - result.stopReason || 'TeammateIdle hook prevented continuation' - yield createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: teammateStopReason, - hookName: 'TeammateIdle', - toolUseID: teammateHookToolUseID, - hookEvent: 'TeammateIdle', - }) - } - if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } - } - } - - if (teammatePreventedContinuation) { - return { blockingErrors: [], preventContinuation: true } - } - - if (teammateBlockingErrors.length > 0) { - return { - blockingErrors: teammateBlockingErrors, - preventContinuation: false, - } - } - } - - return { blockingErrors: [], preventContinuation: false } - } catch (error) { - const durationMs = Date.now() - hookStartTime - logEvent('tengu_stop_hook_error', { - duration: durationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - // Yield a system message that is not visible to the model for the user - // to debug their hook. - yield createSystemMessage( - `Stop hook failed: ${errorMessage(error)}`, - 'warning', - ) - return { blockingErrors: [], preventContinuation: false } - } -} diff --git a/hook-dump/src_schemas_hooks.ts b/hook-dump/src_schemas_hooks.ts deleted file mode 100644 index 743af5eb13..0000000000 --- a/hook-dump/src_schemas_hooks.ts +++ /dev/null @@ -1,225 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\schemas\hooks.ts -LINES: 222 -========== -/** - * Hook Zod schemas extracted to break import cycles. - * - * This file contains hook-related schema definitions that were originally - * in src/utils/settings/types.ts. By extracting them here, we break the - * circular dependency between settings/types.ts and plugins/schemas.ts. - * - * Both files now import from this shared location instead of each other. - */ - -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { z } from 'zod/v4' -import { lazySchema } from '../utils/lazySchema.js' -import { SHELL_TYPES } from '../utils/shell/shellProvider.js' - -// Shared schema for the `if` condition field. -// Uses permission rule syntax (e.g., "Bash(git *)", "Read(*.ts)") to filter hooks -// before spawning. Evaluated against the hook input's tool_name and tool_input. -const IfConditionSchema = lazySchema(() => - z - .string() - .optional() - .describe( - 'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' + - 'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.', - ), -) - -// Internal factory for individual hook schemas (shared between exported -// discriminated union members and the HookCommandSchema factory) -function buildHookSchemas() { - const BashCommandHookSchema = z.object({ - type: z.literal('command').describe('Shell command hook type'), - command: z.string().describe('Shell command to execute'), - if: IfConditionSchema(), - shell: z - .enum(SHELL_TYPES) - .optional() - .describe( - "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.", - ), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific command'), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - async: z - .boolean() - .optional() - .describe('If true, hook runs in background without blocking'), - asyncRewake: z - .boolean() - .optional() - .describe( - 'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.', - ), - }) - - const PromptHookSchema = z.object({ - type: z.literal('prompt').describe('LLM prompt hook type'), - prompt: z - .string() - .describe( - 'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.', - ), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific prompt evaluation'), - // @[MODEL LAUNCH]: Update the example model ID in the .describe() strings below (prompt + agent hooks). - model: z - .string() - .optional() - .describe( - 'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - const HttpHookSchema = z.object({ - type: z.literal('http').describe('HTTP hook type'), - url: z.string().url().describe('URL to POST the hook input JSON to'), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for this specific request'), - headers: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', - ), - allowedEnvVars: z - .array(z.string()) - .optional() - .describe( - 'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - const AgentHookSchema = z.object({ - type: z.literal('agent').describe('Agentic verifier hook type'), - // DO NOT add .transform() here. This schema is used by parseSettingsFile, - // and updateSettingsForSource round-trips the parsed result through - // JSON.stringify 鈥?a transformed function value is silently dropped, - // deleting the user's prompt from settings.json (gh-24920, CC-79). The - // transform (from #10594) wrapped the string in `(_msgs) => prompt` - // for a programmatic-construction use case in ExitPlanModeV2Tool that - // has since been refactored into VerifyPlanExecutionTool, which no - // longer constructs AgentHook objects at all. - prompt: z - .string() - .describe( - 'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.', - ), - if: IfConditionSchema(), - timeout: z - .number() - .positive() - .optional() - .describe('Timeout in seconds for agent execution (default 60)'), - model: z - .string() - .optional() - .describe( - 'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.', - ), - statusMessage: z - .string() - .optional() - .describe('Custom status message to display in spinner while hook runs'), - once: z - .boolean() - .optional() - .describe('If true, hook runs once and is removed after execution'), - }) - - return { - BashCommandHookSchema, - PromptHookSchema, - HttpHookSchema, - AgentHookSchema, - } -} - -/** - * Schema for hook command (excludes function hooks - they can't be persisted) - */ -export const HookCommandSchema = lazySchema(() => { - const { - BashCommandHookSchema, - PromptHookSchema, - AgentHookSchema, - HttpHookSchema, - } = buildHookSchemas() - return z.discriminatedUnion('type', [ - BashCommandHookSchema, - PromptHookSchema, - AgentHookSchema, - HttpHookSchema, - ]) -}) - -/** - * Schema for matcher configuration with multiple hooks - */ -export const HookMatcherSchema = lazySchema(() => - z.object({ - matcher: z - .string() - .optional() - .describe('String pattern to match (e.g. tool names like "Write")'), // String (e.g. Write) to match values related to the hook event, e.g. tool names - hooks: z - .array(HookCommandSchema()) - .describe('List of hooks to execute when the matcher matches'), - }), -) - -/** - * Schema for hooks configuration - * The key is the hook event. The value is an array of matcher configurations. - * Uses partialRecord since not all hook events need to be defined. - */ -export const HooksSchema = lazySchema(() => - z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())), -) - -// Inferred types from schemas -export type HookCommand = z.infer> -export type BashCommandHook = Extract -export type PromptHook = Extract -export type AgentHook = Extract -export type HttpHook = Extract -export type HookMatcher = z.infer> -export type HooksSettings = Partial> diff --git a/hook-dump/src_services_tools_toolHooks.ts b/hook-dump/src_services_tools_toolHooks.ts deleted file mode 100644 index 8af55f2be2..0000000000 --- a/hook-dump/src_services_tools_toolHooks.ts +++ /dev/null @@ -1,655 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\services\tools\toolHooks.ts -LINES: 652 -========== -import { - type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - logEvent, -} from 'src/services/analytics/index.js' -import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js' -import type z from 'zod/v4' -import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' -import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' -import type { HookProgress } from '../../types/hooks.js' -import type { - AssistantMessage, - AttachmentMessage, - ProgressMessage, -} from '../../types/message.js' -import type { PermissionDecision } from '../../types/permissions.js' -import { createAttachmentMessage } from '../../utils/attachments.js' -import { logForDebugging } from '../../utils/debug.js' -import { - executePostToolHooks, - executePostToolUseFailureHooks, - executePreToolHooks, - getPreToolHookBlockingMessage, -} from '../../utils/hooks.js' -import { logError } from '../../utils/log.js' -import { - getRuleBehaviorDescription, - type PermissionDecisionReason, - type PermissionResult, -} from '../../utils/permissions/PermissionResult.js' -import { checkRuleBasedPermissions } from '../../utils/permissions/permissions.js' -import { formatError } from '../../utils/toolErrors.js' -import { isMcpTool } from '../mcp/utils.js' -import type { McpServerType, MessageUpdateLazy } from './toolExecution.js' - -export type PostToolUseHooksResult = - | MessageUpdateLazy> - | { updatedMCPToolOutput: Output } - -export async function* runPostToolUseHooks( - toolUseContext: ToolUseContext, - tool: Tool, - toolUseID: string, - messageId: string, - toolInput: Record, - toolResponse: Output, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator> { - const postToolStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - let toolOutput = toolResponse - for await (const result of executePostToolHooks( - tool.name, - toolUseID, - toolInput, - toolOutput, - toolUseContext, - permissionMode, - toolUseContext.abortController.signal, - )) { - try { - // Check if we were aborted during hook execution - // IMPORTANT: We emit a cancelled event per hook - if ( - result.message?.type === 'attachment' && - result.message.attachment.type === 'hook_cancelled' - ) { - logEvent('tengu_post_tool_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PostToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PostToolUse', - }), - } - continue - } - - // For JSON {decision:"block"} hooks, executeHooks yields two results: - // {blockingError} and {message: hook_blocking_error attachment}. The - // blockingError path below creates that same attachment, so skip it - // here to avoid displaying the block reason twice (#31301). The - // exit-code-2 path only yields {blockingError}, so it's unaffected. - if ( - result.message && - !( - result.message.type === 'attachment' && - result.message.attachment.type === 'hook_blocking_error' - ) - ) { - yield { message: result.message } - } - - if (result.blockingError) { - yield { - message: createAttachmentMessage({ - type: 'hook_blocking_error', - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - blockingError: result.blockingError, - }), - } - } - - // If hook indicated to prevent continuation, yield a stop reason message - if (result.preventContinuation) { - yield { - message: createAttachmentMessage({ - type: 'hook_stopped_continuation', - message: - result.stopReason || 'Execution stopped by PostToolUse hook', - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - return - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - } - - // If hooks provided updatedMCPToolOutput, yield it if this is an MCP tool - if (result.updatedMCPToolOutput && isMcpTool(tool)) { - toolOutput = result.updatedMCPToolOutput as Output - yield { - updatedMCPToolOutput: toolOutput, - } - } - } catch (error) { - const postToolDurationMs = Date.now() - postToolStartTime - logEvent('tengu_post_tool_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: postToolDurationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(error), - hookName: `PostToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUse', - }), - } - } - } - } catch (error) { - logError(error) - } -} - -export async function* runPostToolUseFailureHooks( - toolUseContext: ToolUseContext, - tool: Tool, - toolUseID: string, - messageId: string, - processedInput: z.infer, - error: string, - isInterrupt: boolean | undefined, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator< - MessageUpdateLazy> -> { - const postToolStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode - - for await (const result of executePostToolUseFailureHooks( - tool.name, - toolUseID, - processedInput, - error, - toolUseContext, - isInterrupt, - permissionMode, - toolUseContext.abortController.signal, - )) { - try { - // Check if we were aborted during hook execution - if ( - result.message?.type === 'attachment' && - result.message.attachment.type === 'hook_cancelled' - ) { - logEvent('tengu_post_tool_failure_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - continue - } - - // Skip hook_blocking_error in result.message 鈥?blockingError path - // below creates the same attachment (see #31301 / PostToolUse above). - if ( - result.message && - !( - result.message.type === 'attachment' && - result.message.attachment.type === 'hook_blocking_error' - ) - ) { - yield { message: result.message } - } - - if (result.blockingError) { - yield { - message: createAttachmentMessage({ - type: 'hook_blocking_error', - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - blockingError: result.blockingError, - }), - } - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - } - } catch (hookError) { - const postToolDurationMs = Date.now() - postToolStartTime - logEvent('tengu_post_tool_failure_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: postToolDurationMs, - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(hookError), - hookName: `PostToolUseFailure:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PostToolUseFailure', - }), - } - } - } - } catch (outerError) { - logError(outerError) - } -} - -/** - * Resolve a PreToolUse hook's permission result into a final PermissionDecision. - * - * Encapsulates the invariant that hook 'allow' does NOT bypass settings.json - * deny rules or non-bypass ask rules 鈥?checkRuleBasedPermissions still applies - * (inc-4788 analog). - * Also handles the requiresUserInteraction/requireCanUseTool guards and the - * 'ask' forceDecision passthrough. - * - * Shared by toolExecution.ts (main query loop) and REPLTool/toolWrappers.ts - * (REPL inner calls) so the permission semantics stay in lockstep. - */ -export async function resolveHookPermissionDecision( - hookPermissionResult: PermissionResult | undefined, - tool: Tool, - input: Record, - toolUseContext: ToolUseContext, - canUseTool: CanUseToolFn, - assistantMessage: AssistantMessage, - toolUseID: string, -): Promise<{ - decision: PermissionDecision - input: Record -}> { - const requiresInteraction = tool.requiresUserInteraction?.() - const requireCanUseTool = toolUseContext.requireCanUseTool - - if (hookPermissionResult?.behavior === 'allow') { - const hookInput = hookPermissionResult.updatedInput ?? input - - // Hook provided updatedInput for an interactive tool 鈥?the hook IS the - // user interaction (e.g. headless wrapper that collected AskUserQuestion - // answers). Treat as non-interactive for the rule-check path. - const interactionSatisfied = - requiresInteraction && hookPermissionResult.updatedInput !== undefined - - if ((requiresInteraction && !interactionSatisfied) || requireCanUseTool) { - logForDebugging( - `Hook approved tool use for ${tool.name}, but canUseTool is required`, - ) - return { - decision: await canUseTool( - tool, - hookInput, - toolUseContext, - assistantMessage, - toolUseID, - ), - input: hookInput, - } - } - - // Hook allow skips the interactive prompt, but deny rules and non-bypass - // ask rules still apply. - const ruleCheck = await checkRuleBasedPermissions( - tool, - hookInput, - toolUseContext, - ) - if (ruleCheck === null) { - logForDebugging( - interactionSatisfied - ? `Hook satisfied user interaction for ${tool.name} via updatedInput` - : `Hook approved tool use for ${tool.name}, bypassing permission prompt`, - ) - return { decision: hookPermissionResult, input: hookInput } - } - if (ruleCheck.behavior === 'deny') { - logForDebugging( - `Hook approved tool use for ${tool.name}, but deny rule overrides: ${ruleCheck.message}`, - ) - return { decision: ruleCheck, input: hookInput } - } - // ask rule 鈥?dialog required despite hook approval - logForDebugging( - `Hook approved tool use for ${tool.name}, but ask rule requires prompt`, - ) - return { - decision: await canUseTool( - tool, - hookInput, - toolUseContext, - assistantMessage, - toolUseID, - ), - input: hookInput, - } - } - - if (hookPermissionResult?.behavior === 'deny') { - logForDebugging(`Hook denied tool use for ${tool.name}`) - return { decision: hookPermissionResult, input } - } - - // No hook decision or 'ask' 鈥?normal permission flow, possibly with - // forceDecision so the dialog shows the hook's ask message. - const forceDecision = - hookPermissionResult?.behavior === 'ask' ? hookPermissionResult : undefined - const askInput = - hookPermissionResult?.behavior === 'ask' && - hookPermissionResult.updatedInput - ? hookPermissionResult.updatedInput - : input - return { - decision: await canUseTool( - tool, - askInput, - toolUseContext, - assistantMessage, - toolUseID, - forceDecision, - ), - input: askInput, - } -} - -export async function* runPreToolUseHooks( - toolUseContext: ToolUseContext, - tool: Tool, - processedInput: Record, - toolUseID: string, - messageId: string, - requestId: string | undefined, - mcpServerType: McpServerType, - mcpServerBaseUrl: string | undefined, -): AsyncGenerator< - | { - type: 'message' - message: MessageUpdateLazy< - AttachmentMessage | ProgressMessage - > - } - | { type: 'hookPermissionResult'; hookPermissionResult: PermissionResult } - | { type: 'hookUpdatedInput'; updatedInput: Record } - | { type: 'preventContinuation'; shouldPreventContinuation: boolean } - | { type: 'stopReason'; stopReason: string } - | { - type: 'additionalContext' - message: MessageUpdateLazy - } - // stop execution - | { type: 'stop' } -> { - const hookStartTime = Date.now() - try { - const appState = toolUseContext.getAppState() - - for await (const result of executePreToolHooks( - tool.name, - toolUseID, - processedInput, - toolUseContext, - appState.toolPermissionContext.mode, - toolUseContext.abortController.signal, - undefined, // timeoutMs - use default - toolUseContext.requestPrompt, - tool.getToolUseSummary?.(processedInput), - )) { - try { - if (result.message) { - yield { type: 'message', message: { message: result.message } } - } - if (result.blockingError) { - const denialMessage = getPreToolHookBlockingMessage( - `PreToolUse:${tool.name}`, - result.blockingError, - ) - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'deny', - message: denialMessage, - decisionReason: { - type: 'hook', - hookName: `PreToolUse:${tool.name}`, - reason: denialMessage, - }, - }, - } - } - // Check if hook wants to prevent continuation - if (result.preventContinuation) { - yield { - type: 'preventContinuation', - shouldPreventContinuation: true, - } - if (result.stopReason) { - yield { type: 'stopReason', stopReason: result.stopReason } - } - } - // Check for hook-defined permission behavior - if (result.permissionBehavior !== undefined) { - logForDebugging( - `Hook result has permissionBehavior=${result.permissionBehavior}`, - ) - const decisionReason: PermissionDecisionReason = { - type: 'hook', - hookName: `PreToolUse:${tool.name}`, - hookSource: result.hookSource, - reason: result.hookPermissionDecisionReason, - } - if (result.permissionBehavior === 'allow') { - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'allow', - updatedInput: result.updatedInput, - decisionReason, - }, - } - } else if (result.permissionBehavior === 'ask') { - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: 'ask', - updatedInput: result.updatedInput, - message: - result.hookPermissionDecisionReason || - `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, - decisionReason, - }, - } - } else { - // deny - updatedInput is irrelevant since tool won't run - yield { - type: 'hookPermissionResult', - hookPermissionResult: { - behavior: result.permissionBehavior, - message: - result.hookPermissionDecisionReason || - `Hook PreToolUse:${tool.name} ${getRuleBehaviorDescription(result.permissionBehavior)} this tool`, - decisionReason, - }, - } - } - } - - // Yield updatedInput for passthrough case (no permission decision) - // This allows hooks to modify input while letting normal permission flow continue - if (result.updatedInput && result.permissionBehavior === undefined) { - yield { - type: 'hookUpdatedInput', - updatedInput: result.updatedInput, - } - } - - // If hooks provided additional context, add it as a message - if (result.additionalContexts && result.additionalContexts.length > 0) { - yield { - type: 'additionalContext', - message: { - message: createAttachmentMessage({ - type: 'hook_additional_context', - content: result.additionalContexts, - hookName: `PreToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - } - - // Check if we were aborted during hook execution - if (toolUseContext.abortController.signal.aborted) { - logEvent('tengu_pre_tool_hooks_cancelled', { - toolName: sanitizeToolNameForAnalytics(tool.name), - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - }) - yield { - type: 'message', - message: { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName: `PreToolUse:${tool.name}`, - toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - yield { type: 'stop' } - return - } - } catch (error) { - logError(error) - const durationMs = Date.now() - hookStartTime - logEvent('tengu_pre_tool_hook_error', { - messageID: - messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toolName: sanitizeToolNameForAnalytics(tool.name), - isMcp: tool.isMcp ?? false, - duration: durationMs, - - queryChainId: toolUseContext.queryTracking - ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - queryDepth: toolUseContext.queryTracking?.depth, - ...(mcpServerType - ? { - mcpServerType: - mcpServerType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - ...(requestId - ? { - requestId: - requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}), - }) - yield { - type: 'message', - message: { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - content: formatError(error), - hookName: `PreToolUse:${tool.name}`, - toolUseID: toolUseID, - hookEvent: 'PreToolUse', - }), - }, - } - yield { type: 'stop' } - } - } - } catch (error) { - logError(error) - yield { type: 'stop' } - return - } -} diff --git a/hook-dump/src_types_hooks.ts b/hook-dump/src_types_hooks.ts deleted file mode 100644 index 981f34b0af..0000000000 --- a/hook-dump/src_types_hooks.ts +++ /dev/null @@ -1,293 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\types\hooks.ts -LINES: 290 -========== -// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { z } from 'zod/v4' -import { lazySchema } from '../utils/lazySchema.js' -import { - type HookEvent, - HOOK_EVENTS, - type HookInput, - type PermissionUpdate, -} from 'src/entrypoints/agentSdkTypes.js' -import type { - HookJSONOutput, - AsyncHookJSONOutput, - SyncHookJSONOutput, -} from 'src/entrypoints/agentSdkTypes.js' -import type { Message } from 'src/types/message.js' -import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js' -import { permissionBehaviorSchema } from 'src/utils/permissions/PermissionRule.js' -import { permissionUpdateSchema } from 'src/utils/permissions/PermissionUpdateSchema.js' -import type { AppState } from '../state/AppState.js' -import type { AttributionState } from '../utils/commitAttribution.js' - -export function isHookEvent(value: string): value is HookEvent { - return HOOK_EVENTS.includes(value as HookEvent) -} - -// Prompt elicitation protocol types. The `prompt` key acts as discriminator -// (mirroring the {async:true} pattern), with the id as its value. -export const promptRequestSchema = lazySchema(() => - z.object({ - prompt: z.string(), // request id - message: z.string(), - options: z.array( - z.object({ - key: z.string(), - label: z.string(), - description: z.string().optional(), - }), - ), - }), -) - -export type PromptRequest = z.infer> - -export type PromptResponse = { - prompt_response: string // request id - selected: string -} - -// Sync hook response schema -export const syncHookResponseSchema = lazySchema(() => - z.object({ - continue: z - .boolean() - .describe('Whether Claude should continue after hook (default: true)') - .optional(), - suppressOutput: z - .boolean() - .describe('Hide stdout from transcript (default: false)') - .optional(), - stopReason: z - .string() - .describe('Message shown when continue is false') - .optional(), - decision: z.enum(['approve', 'block']).optional(), - reason: z.string().describe('Explanation for the decision').optional(), - systemMessage: z - .string() - .describe('Warning message shown to the user') - .optional(), - hookSpecificOutput: z - .union([ - z.object({ - hookEventName: z.literal('PreToolUse'), - permissionDecision: permissionBehaviorSchema().optional(), - permissionDecisionReason: z.string().optional(), - updatedInput: z.record(z.string(), z.unknown()).optional(), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('UserPromptSubmit'), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('SessionStart'), - additionalContext: z.string().optional(), - initialUserMessage: z.string().optional(), - watchPaths: z - .array(z.string()) - .describe('Absolute paths to watch for FileChanged hooks') - .optional(), - }), - z.object({ - hookEventName: z.literal('Setup'), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('SubagentStart'), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('PostToolUse'), - additionalContext: z.string().optional(), - updatedMCPToolOutput: z - .unknown() - .describe('Updates the output for MCP tools') - .optional(), - }), - z.object({ - hookEventName: z.literal('PostToolUseFailure'), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('PermissionDenied'), - retry: z.boolean().optional(), - }), - z.object({ - hookEventName: z.literal('Notification'), - additionalContext: z.string().optional(), - }), - z.object({ - hookEventName: z.literal('PermissionRequest'), - decision: z.union([ - z.object({ - behavior: z.literal('allow'), - updatedInput: z.record(z.string(), z.unknown()).optional(), - updatedPermissions: z.array(permissionUpdateSchema()).optional(), - }), - z.object({ - behavior: z.literal('deny'), - message: z.string().optional(), - interrupt: z.boolean().optional(), - }), - ]), - }), - z.object({ - hookEventName: z.literal('Elicitation'), - action: z.enum(['accept', 'decline', 'cancel']).optional(), - content: z.record(z.string(), z.unknown()).optional(), - }), - z.object({ - hookEventName: z.literal('ElicitationResult'), - action: z.enum(['accept', 'decline', 'cancel']).optional(), - content: z.record(z.string(), z.unknown()).optional(), - }), - z.object({ - hookEventName: z.literal('CwdChanged'), - watchPaths: z - .array(z.string()) - .describe('Absolute paths to watch for FileChanged hooks') - .optional(), - }), - z.object({ - hookEventName: z.literal('FileChanged'), - watchPaths: z - .array(z.string()) - .describe('Absolute paths to watch for FileChanged hooks') - .optional(), - }), - z.object({ - hookEventName: z.literal('WorktreeCreate'), - worktreePath: z.string(), - }), - ]) - .optional(), - }), -) - -// Zod schema for hook JSON output validation -export const hookJSONOutputSchema = lazySchema(() => { - // Async hook response schema - const asyncHookResponseSchema = z.object({ - async: z.literal(true), - asyncTimeout: z.number().optional(), - }) - return z.union([asyncHookResponseSchema, syncHookResponseSchema()]) -}) - -// Infer the TypeScript type from the schema -type SchemaHookJSONOutput = z.infer> - -// Type guard function to check if response is sync -export function isSyncHookJSONOutput( - json: HookJSONOutput, -): json is SyncHookJSONOutput { - return !('async' in json && json.async === true) -} - -// Type guard function to check if response is async -export function isAsyncHookJSONOutput( - json: HookJSONOutput, -): json is AsyncHookJSONOutput { - return 'async' in json && json.async === true -} - -// Compile-time assertion that SDK and Zod types match -import type { IsEqual } from 'type-fest' -type Assert = T -type _assertSDKTypesMatch = Assert< - IsEqual -> - -/** Context passed to callback hooks for state access */ -export type HookCallbackContext = { - getAppState: () => AppState - updateAttributionState: ( - updater: (prev: AttributionState) => AttributionState, - ) => void -} - -/** Hook that is a callback. */ -export type HookCallback = { - type: 'callback' - callback: ( - input: HookInput, - toolUseID: string | null, - abort: AbortSignal | undefined, - /** Hook index for SessionStart hooks to compute CLAUDE_ENV_FILE path */ - hookIndex?: number, - /** Optional context for accessing app state */ - context?: HookCallbackContext, - ) => Promise - /** Timeout in seconds for this hook */ - timeout?: number - /** Internal hooks (e.g. session file access analytics) are excluded from tengu_run_hook metrics */ - internal?: boolean -} - -export type HookCallbackMatcher = { - matcher?: string - hooks: HookCallback[] - pluginName?: string -} - -export type HookProgress = { - type: 'hook_progress' - hookEvent: HookEvent - hookName: string - command: string - promptText?: string - statusMessage?: string -} - -export type HookBlockingError = { - blockingError: string - command: string -} - -export type PermissionRequestResult = - | { - behavior: 'allow' - updatedInput?: Record - updatedPermissions?: PermissionUpdate[] - } - | { - behavior: 'deny' - message?: string - interrupt?: boolean - } - -export type HookResult = { - message?: Message - systemMessage?: Message - blockingError?: HookBlockingError - outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' - preventContinuation?: boolean - stopReason?: string - permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' - hookPermissionDecisionReason?: string - additionalContext?: string - initialUserMessage?: string - updatedInput?: Record - updatedMCPToolOutput?: unknown - permissionRequestResult?: PermissionRequestResult - retry?: boolean -} - -export type AggregatedHookResult = { - message?: Message - blockingErrors?: HookBlockingError[] - preventContinuation?: boolean - stopReason?: string - hookPermissionDecisionReason?: string - permissionBehavior?: PermissionResult['behavior'] - additionalContexts?: string[] - initialUserMessage?: string - updatedInput?: Record - updatedMCPToolOutput?: unknown - permissionRequestResult?: PermissionRequestResult - retry?: boolean -} diff --git a/hook-dump/src_utils_hooks.ts b/hook-dump/src_utils_hooks.ts deleted file mode 100644 index 1cdd58b7bc..0000000000 --- a/hook-dump/src_utils_hooks.ts +++ /dev/null @@ -1,5044 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks.ts -LINES: 5041 -========== -// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -/** - * Hooks are user-defined shell commands that can be executed at various points - * in Claude Code's lifecycle. - */ -import { basename } from 'path' -import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' -import { pathExists } from './file.js' -import { wrapSpawn } from './ShellCommand.js' -import { TaskOutput } from './task/TaskOutput.js' -import { getCwd } from './cwd.js' -import { randomUUID } from 'crypto' -import { formatShellPrefixCommand } from './bash/shellPrefix.js' -import { - getHookEnvFilePath, - invalidateSessionEnvCache, -} from './sessionEnvironment.js' -import { subprocessEnv } from './subprocessEnv.js' -import { getPlatform } from './platform.js' -import { - tryFindGitBashPath, - windowsPathToPosixPath, -} from './windowsPaths.js' -import { getCachedPowerShellPath } from './shell/powershellDetection.js' -import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js' -import { buildPowerShellArgs } from './shell/powershellProvider.js' -import { - loadPluginOptions, - substituteUserConfigVariables, -} from './plugins/pluginOptionsStorage.js' -import { getPluginDataDir } from './plugins/pluginDirectories.js' -import { - getSessionId, - getProjectRoot, - getIsNonInteractiveSession, - getRegisteredHooks, - getStatsStore, - addToTurnHookDuration, - getOriginalCwd, - getMainThreadAgentType, -} from '../bootstrap/state.js' -import { checkHasTrustDialogAccepted } from './config.js' -import { - getHooksConfigFromSnapshot, - shouldAllowManagedHooksOnly, - shouldDisableAllHooksIncludingManaged, -} from './hooks/hooksConfigSnapshot.js' -import { - getTranscriptPathForSession, - getAgentTranscriptPath, -} from './sessionStorage.js' -import type { AgentId } from '../types/ids.js' -import { - getSettings_DEPRECATED, - getSettingsForSource, -} from './settings/settings.js' -import { - logEvent, - type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, -} from 'src/services/analytics/index.js' -import { logOTelEvent } from './telemetry/events.js' -import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js' -import { - startHookSpan, - endHookSpan, - isBetaTracingEnabled, -} from './telemetry/sessionTracing.js' -import { - hookJSONOutputSchema, - promptRequestSchema, - type HookCallback, - type HookCallbackMatcher, - type PromptRequest, - type PromptResponse, - isAsyncHookJSONOutput, - isSyncHookJSONOutput, - type PermissionRequestResult, -} from '../types/hooks.js' -import type { - HookEvent, - HookInput, - HookJSONOutput, - NotificationHookInput, - PostToolUseHookInput, - PostToolUseFailureHookInput, - PermissionDeniedHookInput, - PreCompactHookInput, - PostCompactHookInput, - PreToolUseHookInput, - SessionStartHookInput, - SessionEndHookInput, - SetupHookInput, - StopHookInput, - StopFailureHookInput, - SubagentStartHookInput, - SubagentStopHookInput, - TeammateIdleHookInput, - TaskCreatedHookInput, - TaskCompletedHookInput, - ConfigChangeHookInput, - CwdChangedHookInput, - FileChangedHookInput, - InstructionsLoadedHookInput, - UserPromptSubmitHookInput, - PermissionRequestHookInput, - ElicitationHookInput, - ElicitationResultHookInput, - PermissionUpdate, - ExitReason, - SyncHookJSONOutput, - AsyncHookJSONOutput, -} from 'src/entrypoints/agentSdkTypes.js' -import type { StatusLineCommandInput } from '../types/statusLine.js' -import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js' -import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js' -import type { HookResultMessage } from 'src/types/message.js' -import chalk from 'chalk' -import type { - HookMatcher, - HookCommand, - PluginHookMatcher, - SkillHookMatcher, -} from './settings/types.js' -import { getHookDisplayText } from './hooks/hooksSettings.js' -import { logForDebugging } from './debug.js' -import { logForDiagnosticsNoPII } from './diagLogs.js' -import { firstLineOf } from './stringUtils.js' -import { - normalizeLegacyToolName, - getLegacyToolNames, - permissionRuleValueFromString, -} from './permissions/permissionRuleParser.js' -import { logError } from './log.js' -import { createCombinedAbortSignal } from './combinedAbortSignal.js' -import type { PermissionResult } from './permissions/PermissionResult.js' -import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' -import { enqueuePendingNotification } from './messageQueueManager.js' -import { - extractTextContent, - getLastAssistantMessage, - wrapInSystemReminder, -} from './messages.js' -import { - emitHookStarted, - emitHookResponse, - startHookProgressInterval, -} from './hooks/hookEvents.js' -import { createAttachmentMessage } from './attachments.js' -import { all } from './generators.js' -import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' -import { execPromptHook } from './hooks/execPromptHook.js' -import type { Message, AssistantMessage } from '../types/message.js' -import { execAgentHook } from './hooks/execAgentHook.js' -import { execHttpHook } from './hooks/execHttpHook.js' -import type { ShellCommand } from './ShellCommand.js' -import { - getSessionHooks, - getSessionFunctionHooks, - getSessionHookCallback, - clearSessionHooks, - type SessionDerivedHookMatcher, - type FunctionHook, -} from './hooks/sessionHooks.js' -import type { AppState } from '../state/AppState.js' -import { jsonStringify, jsonParse } from './slowOperations.js' -import { isEnvTruthy } from './envUtils.js' -import { errorMessage, getErrnoCode } from './errors.js' - -const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 - -/** - * SessionEnd hooks run during shutdown/clear and need a much tighter bound - * than TOOL_HOOK_EXECUTION_TIMEOUT_MS. This value is used by callers as both - * the per-hook default timeout AND the overall AbortSignal cap (hooks run in - * parallel, so one value suffices). Overridable via env var for users whose - * teardown scripts need more time. - */ -const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 -export function getSessionEndHookTimeoutMs(): number { - const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS - const parsed = raw ? parseInt(raw, 10) : NaN - return Number.isFinite(parsed) && parsed > 0 - ? parsed - : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT -} - -function executeInBackground({ - processId, - hookId, - shellCommand, - asyncResponse, - hookEvent, - hookName, - command, - asyncRewake, - pluginId, -}: { - processId: string - hookId: string - shellCommand: ShellCommand - asyncResponse: AsyncHookJSONOutput - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - hookName: string - command: string - asyncRewake?: boolean - pluginId?: string -}): boolean { - if (asyncRewake) { - // asyncRewake hooks bypass the registry entirely. On completion, if exit - // code 2 (blocking error), enqueue as a task-notification so it wakes the - // model via useQueueProcessor (idle) or gets injected mid-query via - // queued_command attachments (busy). - // - // NOTE: We deliberately do NOT call shellCommand.background() here, because - // it calls taskOutput.spillToDisk() which breaks in-memory stdout/stderr - // capture (getStderr() returns '' in disk mode). The StreamWrappers stay - // attached and pipe data into the in-memory TaskOutput buffers. The abort - // handler already no-ops on 'interrupt' reason (user submitted a new - // message), so the hook survives new prompts. A hard cancel (Escape) WILL - // kill the hook via the abort handler, which is the desired behavior. - void shellCommand.result.then(async result => { - // result resolves on 'exit', but stdio 'data' events may still be - // pending. Yield to I/O so the StreamWrapper data handlers drain into - // TaskOutput before we read it. - await new Promise(resolve => setImmediate(resolve)) - const stdout = await shellCommand.taskOutput.getStdout() - const stderr = shellCommand.taskOutput.getStderr() - shellCommand.cleanup() - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: stdout + stderr, - stdout, - stderr, - exitCode: result.code, - outcome: result.code === 0 ? 'success' : 'error', - }) - if (result.code === 2) { - enqueuePendingNotification({ - value: wrapInSystemReminder( - `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`, - ), - mode: 'task-notification', - }) - } - }) - return true - } - - // TaskOutput on the ShellCommand accumulates data 鈥?no stream listeners needed - if (!shellCommand.background(processId)) { - return false - } - - registerPendingAsyncHook({ - processId, - hookId, - asyncResponse, - hookEvent, - hookName, - command, - shellCommand, - pluginId, - }) - - return true -} - -/** - * Checks if a hook should be skipped due to lack of workspace trust. - * - * ALL hooks require workspace trust because they execute arbitrary commands from - * .claude/settings.json. This is a defense-in-depth security measure. - * - * Context: Hooks are captured via captureHooksConfigSnapshot() before the trust - * dialog is shown. While most hooks won't execute until after trust is established - * through normal program flow, enforcing trust for ALL hooks prevents: - * - Future bugs where a hook might accidentally execute before trust - * - Any codepath that might trigger hooks before trust dialog - * - Security issues from hook execution in untrusted workspaces - * - * Historical vulnerabilities that prompted this check: - * - SessionEnd hooks executing when user declines trust dialog - * - SubagentStop hooks executing when subagent completes before trust - * - * @returns true if hook should be skipped, false if it should execute - */ -export function shouldSkipHookDueToTrust(): boolean { - // In non-interactive mode (SDK), trust is implicit - always execute - const isInteractive = !getIsNonInteractiveSession() - if (!isInteractive) { - return false - } - - // In interactive mode, ALL hooks require trust - const hasTrust = checkHasTrustDialogAccepted() - return !hasTrust -} - -/** - * Creates the base hook input that's common to all hook types - */ -export function createBaseHookInput( - permissionMode?: string, - sessionId?: string, - // Typed narrowly (not ToolUseContext) so callers can pass toolUseContext - // directly via structural typing without this function depending on Tool.ts. - agentInfo?: { agentId?: string; agentType?: string }, -): { - session_id: string - transcript_path: string - cwd: string - permission_mode?: string - agent_id?: string - agent_type?: string -} { - const resolvedSessionId = sessionId ?? getSessionId() - // agent_type: subagent's type (from toolUseContext) takes precedence over - // the session's --agent flag. Hooks use agent_id presence to distinguish - // subagent calls from main-thread calls in a --agent session. - const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType() - return { - session_id: resolvedSessionId, - transcript_path: getTranscriptPathForSession(resolvedSessionId), - cwd: getCwd(), - permission_mode: permissionMode, - agent_id: agentInfo?.agentId, - agent_type: resolvedAgentType, - } -} - -export interface HookBlockingError { - blockingError: string - command: string -} - -/** Re-export ElicitResult from MCP SDK as ElicitationResponse for backward compat. */ -export type ElicitationResponse = ElicitResult - -export interface HookResult { - message?: HookResultMessage - systemMessage?: string - blockingError?: HookBlockingError - outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' - preventContinuation?: boolean - stopReason?: string - permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' - hookPermissionDecisionReason?: string - additionalContext?: string - initialUserMessage?: string - updatedInput?: Record - updatedMCPToolOutput?: unknown - permissionRequestResult?: PermissionRequestResult - elicitationResponse?: ElicitationResponse - watchPaths?: string[] - elicitationResultResponse?: ElicitationResponse - retry?: boolean - hook: HookCommand | HookCallback | FunctionHook -} - -export type AggregatedHookResult = { - message?: HookResultMessage - blockingError?: HookBlockingError - preventContinuation?: boolean - stopReason?: string - hookPermissionDecisionReason?: string - hookSource?: string - permissionBehavior?: PermissionResult['behavior'] - additionalContexts?: string[] - initialUserMessage?: string - updatedInput?: Record - updatedMCPToolOutput?: unknown - permissionRequestResult?: PermissionRequestResult - watchPaths?: string[] - elicitationResponse?: ElicitationResponse - elicitationResultResponse?: ElicitationResponse - retry?: boolean -} - -/** - * Parse and validate a JSON string against the hook output Zod schema. - * Returns the validated output or formatted validation errors. - */ -function validateHookJson( - jsonString: string, -): { json: HookJSONOutput } | { validationError: string } { - const parsed = jsonParse(jsonString) - const validation = hookJSONOutputSchema().safeParse(parsed) - if (validation.success) { - logForDebugging('Successfully parsed and validated hook JSON output') - return { json: validation.data } - } - const errors = validation.error.issues - .map(err => ` - ${err.path.join('.')}: ${err.message}`) - .join('\n') - return { - validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`, - } -} - -function parseHookOutput(stdout: string): { - json?: HookJSONOutput - plainText?: string - validationError?: string -} { - const trimmed = stdout.trim() - if (!trimmed.startsWith('{')) { - logForDebugging('Hook output does not start with {, treating as plain text') - return { plainText: stdout } - } - - try { - const result = validateHookJson(trimmed) - if ('json' in result) { - return result - } - // For command hooks, include the schema hint in the error message - const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify( - { - continue: 'boolean (optional)', - suppressOutput: 'boolean (optional)', - stopReason: 'string (optional)', - decision: '"approve" | "block" (optional)', - reason: 'string (optional)', - systemMessage: 'string (optional)', - permissionDecision: '"allow" | "deny" | "ask" (optional)', - hookSpecificOutput: { - 'for PreToolUse': { - hookEventName: '"PreToolUse"', - permissionDecision: '"allow" | "deny" | "ask" (optional)', - permissionDecisionReason: 'string (optional)', - updatedInput: 'object (optional) - Modified tool input to use', - }, - 'for UserPromptSubmit': { - hookEventName: '"UserPromptSubmit"', - additionalContext: 'string (required)', - }, - 'for PostToolUse': { - hookEventName: '"PostToolUse"', - additionalContext: 'string (optional)', - }, - }, - }, - null, - 2, - )}` - logForDebugging(errorMessage) - return { plainText: stdout, validationError: errorMessage } - } catch (e) { - logForDebugging(`Failed to parse hook output as JSON: ${e}`) - return { plainText: stdout } - } -} - -function parseHttpHookOutput(body: string): { - json?: HookJSONOutput - validationError?: string -} { - const trimmed = body.trim() - - if (trimmed === '') { - const validation = hookJSONOutputSchema().safeParse({}) - if (validation.success) { - logForDebugging( - 'HTTP hook returned empty body, treating as empty JSON object', - ) - return { json: validation.data } - } - } - - if (!trimmed.startsWith('{')) { - const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}` - logForDebugging(validationError) - return { validationError } - } - - try { - const result = validateHookJson(trimmed) - if ('json' in result) { - return result - } - logForDebugging(result.validationError) - return result - } catch (e) { - const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}` - logForDebugging(validationError) - return { validationError } - } -} - -function processHookJSONOutput({ - json, - command, - hookName, - toolUseID, - hookEvent, - expectedHookEvent, - stdout, - stderr, - exitCode, - durationMs, -}: { - json: SyncHookJSONOutput - command: string - hookName: string - toolUseID: string - hookEvent: HookEvent - expectedHookEvent?: HookEvent - stdout?: string - stderr?: string - exitCode?: number - durationMs?: number -}): Partial { - const result: Partial = {} - - // At this point we know it's a sync response - const syncJson = json - - // Handle common elements - if (syncJson.continue === false) { - result.preventContinuation = true - if (syncJson.stopReason) { - result.stopReason = syncJson.stopReason - } - } - - if (json.decision) { - switch (json.decision) { - case 'approve': - result.permissionBehavior = 'allow' - break - case 'block': - result.permissionBehavior = 'deny' - result.blockingError = { - blockingError: json.reason || 'Blocked by hook', - command, - } - break - default: - // Handle unknown decision types as errors - throw new Error( - `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`, - ) - } - } - - // Handle systemMessage field - if (json.systemMessage) { - result.systemMessage = json.systemMessage - } - - // Handle PreToolUse specific - if ( - json.hookSpecificOutput?.hookEventName === 'PreToolUse' && - json.hookSpecificOutput.permissionDecision - ) { - switch (json.hookSpecificOutput.permissionDecision) { - case 'allow': - result.permissionBehavior = 'allow' - break - case 'deny': - result.permissionBehavior = 'deny' - result.blockingError = { - blockingError: json.reason || 'Blocked by hook', - command, - } - break - case 'ask': - result.permissionBehavior = 'ask' - break - default: - // Handle unknown decision types as errors - throw new Error( - `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`, - ) - } - } - if (result.permissionBehavior !== undefined && json.reason !== undefined) { - result.hookPermissionDecisionReason = json.reason - } - - // Handle hookSpecificOutput - if (json.hookSpecificOutput) { - // Validate hook event name matches expected if provided - if ( - expectedHookEvent && - json.hookSpecificOutput.hookEventName !== expectedHookEvent - ) { - throw new Error( - `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`, - ) - } - - switch (json.hookSpecificOutput.hookEventName) { - case 'PreToolUse': - // Override with more specific permission decision if provided - if (json.hookSpecificOutput.permissionDecision) { - switch (json.hookSpecificOutput.permissionDecision) { - case 'allow': - result.permissionBehavior = 'allow' - break - case 'deny': - result.permissionBehavior = 'deny' - result.blockingError = { - blockingError: - json.hookSpecificOutput.permissionDecisionReason || - json.reason || - 'Blocked by hook', - command, - } - break - case 'ask': - result.permissionBehavior = 'ask' - break - } - } - result.hookPermissionDecisionReason = - json.hookSpecificOutput.permissionDecisionReason - // Extract updatedInput if provided - if (json.hookSpecificOutput.updatedInput) { - result.updatedInput = json.hookSpecificOutput.updatedInput - } - // Extract additionalContext if provided - result.additionalContext = json.hookSpecificOutput.additionalContext - break - case 'UserPromptSubmit': - result.additionalContext = json.hookSpecificOutput.additionalContext - break - case 'SessionStart': - result.additionalContext = json.hookSpecificOutput.additionalContext - result.initialUserMessage = json.hookSpecificOutput.initialUserMessage - if ( - 'watchPaths' in json.hookSpecificOutput && - json.hookSpecificOutput.watchPaths - ) { - result.watchPaths = json.hookSpecificOutput.watchPaths - } - break - case 'Setup': - result.additionalContext = json.hookSpecificOutput.additionalContext - break - case 'SubagentStart': - result.additionalContext = json.hookSpecificOutput.additionalContext - break - case 'PostToolUse': - result.additionalContext = json.hookSpecificOutput.additionalContext - // Extract updatedMCPToolOutput if provided - if (json.hookSpecificOutput.updatedMCPToolOutput) { - result.updatedMCPToolOutput = - json.hookSpecificOutput.updatedMCPToolOutput - } - break - case 'PostToolUseFailure': - result.additionalContext = json.hookSpecificOutput.additionalContext - break - case 'PermissionDenied': - result.retry = json.hookSpecificOutput.retry - break - case 'PermissionRequest': - // Extract the permission request decision - if (json.hookSpecificOutput.decision) { - result.permissionRequestResult = json.hookSpecificOutput.decision - // Also update permissionBehavior for consistency - result.permissionBehavior = - json.hookSpecificOutput.decision.behavior === 'allow' - ? 'allow' - : 'deny' - if ( - json.hookSpecificOutput.decision.behavior === 'allow' && - json.hookSpecificOutput.decision.updatedInput - ) { - result.updatedInput = json.hookSpecificOutput.decision.updatedInput - } - } - break - case 'Elicitation': - if (json.hookSpecificOutput.action) { - result.elicitationResponse = { - action: json.hookSpecificOutput.action, - content: json.hookSpecificOutput.content as - | ElicitationResponse['content'] - | undefined, - } - if (json.hookSpecificOutput.action === 'decline') { - result.blockingError = { - blockingError: json.reason || 'Elicitation denied by hook', - command, - } - } - } - break - case 'ElicitationResult': - if (json.hookSpecificOutput.action) { - result.elicitationResultResponse = { - action: json.hookSpecificOutput.action, - content: json.hookSpecificOutput.content as - | ElicitationResponse['content'] - | undefined, - } - if (json.hookSpecificOutput.action === 'decline') { - result.blockingError = { - blockingError: - json.reason || 'Elicitation result blocked by hook', - command, - } - } - } - break - } - } - - return { - ...result, - message: result.blockingError - ? createAttachmentMessage({ - type: 'hook_blocking_error', - hookName, - toolUseID, - hookEvent, - blockingError: result.blockingError, - }) - : createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID, - hookEvent, - // JSON-output hooks inject context via additionalContext 鈫? - // hook_additional_context, not this field. Empty content suppresses - // the trivial "X hook success: Success" system-reminder that - // otherwise pollutes every turn (messages.ts:3577 skips on ''). - content: '', - stdout, - stderr, - exitCode, - command, - durationMs, - }), - } -} - -/** - * Execute a command-based hook using bash or PowerShell. - * - * Shell resolution: hook.shell 鈫?'bash'. PowerShell hooks spawn pwsh - * with -NoProfile -NonInteractive -Command and skip bash-specific prep - * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX). - * See docs/design/ps-shell-selection.md 搂5.1. - */ -async function execCommandHook( - hook: HookCommand & { type: 'command' }, - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion', - hookName: string, - jsonInput: string, - signal: AbortSignal, - hookId: string, - hookIndex?: number, - pluginRoot?: string, - pluginId?: string, - skillRoot?: string, - forceSyncExecution?: boolean, - requestPrompt?: (request: PromptRequest) => Promise, -): Promise<{ - stdout: string - stderr: string - output: string - status: number - aborted?: boolean - backgrounded?: boolean -}> { - // Gated to once-per-session events to keep diag_log volume bounded. - // started/completed live inside the try/finally so setup-path throws - // don't orphan a started marker 鈥?that'd be indistinguishable from a hang. - const shouldEmitDiag = - hookEvent === 'SessionStart' || - hookEvent === 'Setup' || - hookEvent === 'SessionEnd' - const diagStartMs = Date.now() - let diagExitCode: number | undefined - let diagAborted = false - - const isWindows = getPlatform() === 'windows' - - // -- - // Per-hook shell selection (phase 1 of docs/design/ps-shell-selection.md). - // Resolution order: hook.shell 鈫?DEFAULT_HOOK_SHELL. The defaultShell - // fallback (settings.defaultShell) is phase 2 鈥?not wired yet. - // - // The bash path is the historical default and stays unchanged. The - // PowerShell path deliberately skips the Windows-specific bash - // accommodations (cygpath conversion, .sh auto-prepend, POSIX-quoted - // SHELL_PREFIX). - let shellType = hook.shell ?? DEFAULT_HOOK_SHELL - - if (isWindows && !hook.shell && shellType === 'bash' && !tryFindGitBashPath()) { - const pwshPath = await getCachedPowerShellPath() - if (pwshPath) { - shellType = 'powershell' - logForDebugging( - `Hooks: Git Bash unavailable on Windows, falling back to PowerShell for default shell on hook "${hook.command}"`, - { level: 'warn' }, - ) - } - } - - const usesPowerShell = shellType === 'powershell' - - // -- - // Windows bash path: hooks run via Git Bash (Cygwin), NOT cmd.exe. - // - // This means every path we put into env vars or substitute into the command - // string MUST be a POSIX path (/c/Users/foo), not a Windows path - // (C:\Users\foo or C:/Users/foo). Git Bash cannot resolve Windows paths. - // - // windowsPathToPosixPath() is pure-JS regex conversion (no cygpath shell-out): - // C:\Users\foo -> /c/Users/foo, UNC preserved, slashes flipped. Memoized - // (LRU-500) so repeated calls are cheap. - // - // PowerShell path: use native paths 鈥?skip the conversion entirely. - // PowerShell expects Windows paths on Windows (and native paths on - // Unix where pwsh is also available). - const toHookPath = - isWindows && !usesPowerShell - ? (p: string) => windowsPathToPosixPath(p) - : (p: string) => p - - // Set CLAUDE_PROJECT_DIR to the stable project root (not the worktree path). - // getProjectRoot() is never updated when entering a worktree, so hooks that - // reference $CLAUDE_PROJECT_DIR always resolve relative to the real repo root. - const projectDir = getProjectRoot() - - // Substitute ${CLAUDE_PLUGIN_ROOT} and ${user_config.X} in the command string. - // Order matches MCP/LSP (plugin vars FIRST, then user config) so a user- - // entered value containing the literal text ${CLAUDE_PLUGIN_ROOT} is treated - // as opaque 鈥?not re-interpreted as a template. - let command = hook.command - let pluginOpts: ReturnType | undefined - if (pluginRoot) { - // Plugin directory gone (orphan GC race, concurrent session deleted it): - // throw so callers yield a non-blocking error. Running would fail 鈥?and - // `python3 .py` exits 2, the hook protocol's "block" code, which - // bricks UserPromptSubmit/Stop until restart. The pre-check is necessary - // because exit-2-from-missing-script is indistinguishable from an - // intentional block after spawn. - if (!(await pathExists(pluginRoot))) { - throw new Error( - `Plugin directory does not exist: ${pluginRoot}` + - (pluginId ? ` (${pluginId} 鈥?run /plugin to reinstall)` : ''), - ) - } - // Inline both ROOT and DATA substitution instead of calling - // substitutePluginVariables(). That helper normalizes \ 鈫?/ on Windows - // unconditionally 鈥?correct for bash (toHookPath already produced /c/... - // so it's a no-op) but wrong for PS where toHookPath is identity and we - // want native C:\... backslashes. Inlining also lets us use the function- - // form .replace() so paths containing $ aren't mangled by $-pattern - // interpretation (rare but possible: \\server\c$\plugin). - const rootPath = toHookPath(pluginRoot) - command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath) - if (pluginId) { - const dataPath = toHookPath(getPluginDataDir(pluginId)) - command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath) - } - if (pluginId) { - pluginOpts = loadPluginOptions(pluginId) - // Throws if a referenced key is missing 鈥?that means the hook uses a key - // that's either not declared in manifest.userConfig or not yet configured. - // Caught upstream like any other hook exec failure. - command = substituteUserConfigVariables(command, pluginOpts) - } - } - - // On Windows (bash only), auto-prepend `bash` for .sh scripts so they - // execute instead of opening in the default file handler. PowerShell - // runs .ps1 files natively 鈥?no prepend needed. - if (isWindows && !usesPowerShell && command.trim().match(/\.sh(\s|$|")/)) { - if (!command.trim().startsWith('bash ')) { - command = `bash ${command}` - } - } - - // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting - // (formatShellPrefixCommand uses shell-quote). This makes no sense for - // PowerShell 鈥?see design 搂8.1. For now PS hooks ignore the prefix; - // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up. - const finalCommand = - !usesPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX - ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command) - : command - - const hookTimeoutMs = hook.timeout - ? hook.timeout * 1000 - : TOOL_HOOK_EXECUTION_TIMEOUT_MS - - // Build env vars 鈥?all paths go through toHookPath for Windows POSIX conversion - const envVars: NodeJS.ProcessEnv = { - ...subprocessEnv(), - CLAUDE_PROJECT_DIR: toHookPath(projectDir), - } - - // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same - // name for consistency 鈥?skills can migrate to plugins without code changes) - if (pluginRoot) { - envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot) - if (pluginId) { - envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId)) - } - } - // Expose plugin options as env vars too, so hooks can read them without - // ${user_config.X} in the command string. Sensitive values included 鈥?hooks - // run the user's own code, same trust boundary as reading keychain directly. - if (pluginOpts) { - for (const [key, value] of Object.entries(pluginOpts)) { - // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema - // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is - // belt-and-suspenders, but cheap insurance if someone bypasses the schema. - const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase() - envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value) - } - } - if (skillRoot) { - envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot) - } - - // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var - // definitions into; getSessionEnvironmentScript() concatenates them and - // bashProvider injects the content into bash commands. A PS hook would - // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse. - // Skip for PS 鈥?consistent with how .sh prepend and SHELL_PREFIX are - // already bash-only above. - if ( - !usesPowerShell && - (hookEvent === 'SessionStart' || - hookEvent === 'Setup' || - hookEvent === 'CwdChanged' || - hookEvent === 'FileChanged') && - hookIndex !== undefined - ) { - envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex) - } - - // When agent worktrees are removed, getCwd() may return a deleted path via - // AsyncLocalStorage. Validate before spawning since spawn() emits async - // 'error' events for missing cwd rather than throwing synchronously. - const hookCwd = getCwd() - const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd() - if (safeCwd !== hookCwd) { - logForDebugging( - `Hooks: cwd ${hookCwd} not found, falling back to original cwd`, - { level: 'warn' }, - ) - } - - // -- - // Spawn. Two completely separate paths: - // - // Bash: spawn(cmd, [], { shell: }) 鈥?the shell - // option makes Node pass the whole string to the shell for parsing. - // - // PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive', - // '-Command', cmd]) 鈥?explicit argv, no shell option. -NoProfile - // skips user profile scripts (faster, deterministic). - // -NonInteractive fails fast instead of prompting. - // - // On Windows, default-shell hooks now degrade to PowerShell when Git Bash - // is unavailable. Explicit bash hooks still surface an actionable error, - // but they no longer hard-exit the entire process. - let child: ChildProcessWithoutNullStreams - if (shellType === 'powershell') { - const pwshPath = await getCachedPowerShellPath() - if (!pwshPath) { - throw new Error( - `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` + - `executable (pwsh or powershell) was found on PATH. Install ` + - `PowerShell, or remove "shell": "powershell" to use bash.`, - ) - } - child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { - env: envVars, - cwd: safeCwd, - // Prevent visible console window on Windows (no-op on other platforms) - windowsHide: true, - }) as ChildProcessWithoutNullStreams - } else { - // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax). - // On other platforms, shell: true uses /bin/sh. - const shell = isWindows - ? tryFindGitBashPath() ?? - (() => { - throw new Error( - 'Git Bash is required to run bash hooks on Windows. Install Git for Windows or configure the hook with shell: "powershell".', - ) - })() - : true - child = spawn(finalCommand, [], { - env: envVars, - cwd: safeCwd, - shell, - // Prevent visible console window on Windows (no-op on other platforms) - windowsHide: true, - }) as ChildProcessWithoutNullStreams - } - - // Hooks use pipe mode 鈥?stdout must be streamed into JS so we can parse - // the first response line to detect async hooks ({"async": true}). - const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null) - const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput) - // Track whether shellCommand ownership was transferred (e.g., to async hook registry) - let shellCommandTransferred = false - // Track whether stdin has already been written (to avoid "write after end" errors) - let stdinWritten = false - - if ((hook.async || hook.asyncRewake) && !forceSyncExecution) { - const processId = `async_hook_${child.pid}` - logForDebugging( - `Hooks: Config-based async hook, backgrounding process ${processId}`, - ) - - // Write stdin before backgrounding so the hook receives its input. - // The trailing newline matches the sync path (L1000). Without it, - // bash `read -r line` returns exit 1 (EOF before delimiter) 鈥?the - // variable IS populated but `if read -r line; then ...` skips the - // branch. See gh-30509 / CC-161. - child.stdin.write(jsonInput + '\n', 'utf8') - child.stdin.end() - stdinWritten = true - - const backgrounded = executeInBackground({ - processId, - hookId, - shellCommand, - asyncResponse: { async: true, asyncTimeout: hookTimeoutMs }, - hookEvent, - hookName, - command: hook.command, - asyncRewake: hook.asyncRewake, - pluginId, - }) - if (backgrounded) { - return { - stdout: '', - stderr: '', - output: '', - status: 0, - backgrounded: true, - } - } - } - - let stdout = '' - let stderr = '' - let output = '' - - // Set up output data collection with explicit UTF-8 encoding - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - - let initialResponseChecked = false - - let asyncResolve: - | ((result: { - stdout: string - stderr: string - output: string - status: number - }) => void) - | null = null - const childIsAsyncPromise = new Promise<{ - stdout: string - stderr: string - output: string - status: number - aborted?: boolean - }>(resolve => { - asyncResolve = resolve - }) - - // Track trimmed prompt-request lines we processed so we can strip them - // from final stdout by content match (no index tracking 鈫?no index drift) - const processedPromptLines = new Set() - // Serialize async prompt handling so responses are sent in order - let promptChain = Promise.resolve() - // Line buffer for detecting prompt requests in streaming output - let lineBuffer = '' - - child.stdout.on('data', data => { - stdout += data - output += data - - // When requestPrompt is provided, parse stdout line-by-line for prompt requests - if (requestPrompt) { - lineBuffer += data - const lines = lineBuffer.split('\n') - lineBuffer = lines.pop() ?? '' // last element is an incomplete line - - for (const line of lines) { - const trimmed = line.trim() - if (!trimmed) continue - - try { - const parsed = jsonParse(trimmed) - const validation = promptRequestSchema().safeParse(parsed) - if (validation.success) { - processedPromptLines.add(trimmed) - logForDebugging( - `Hooks: Detected prompt request from hook: ${trimmed}`, - ) - // Chain the async handling to serialize prompt responses - const promptReq = validation.data - const reqPrompt = requestPrompt - promptChain = promptChain.then(async () => { - try { - const response = await reqPrompt(promptReq) - child.stdin.write(jsonStringify(response) + '\n', 'utf8') - } catch (err) { - logForDebugging(`Hooks: Prompt request handling failed: ${err}`) - // User cancelled or prompt failed 鈥?close stdin so the hook - // process doesn't hang waiting for input - child.stdin.destroy() - } - }) - continue - } - } catch { - // Not JSON, just a normal line - } - } - } - - // Check for async response on first line of output. The async protocol is: - // hook emits {"async":true,...} as its FIRST line, then its normal output. - // We must parse ONLY the first line 鈥?if the process is fast and writes more - // before this 'data' event fires, parsing the full accumulated stdout fails - // and an async hook blocks for its full duration instead of backgrounding. - if (!initialResponseChecked) { - const firstLine = firstLineOf(stdout).trim() - if (!firstLine.includes('}')) return - initialResponseChecked = true - logForDebugging(`Hooks: Checking first line for async: ${firstLine}`) - try { - const parsed = jsonParse(firstLine) - logForDebugging( - `Hooks: Parsed initial response: ${jsonStringify(parsed)}`, - ) - if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) { - const processId = `async_hook_${child.pid}` - logForDebugging( - `Hooks: Detected async hook, backgrounding process ${processId}`, - ) - - const backgrounded = executeInBackground({ - processId, - hookId, - shellCommand, - asyncResponse: parsed, - hookEvent, - hookName, - command: hook.command, - pluginId, - }) - if (backgrounded) { - shellCommandTransferred = true - asyncResolve?.({ - stdout, - stderr, - output, - status: 0, - }) - } - } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) { - logForDebugging( - `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`, - ) - } else { - logForDebugging( - `Hooks: Initial response is not async, continuing normal processing`, - ) - } - } catch (e) { - logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`) - } - } - }) - - child.stderr.on('data', data => { - stderr += data - output += data - }) - - const stopProgressInterval = startHookProgressInterval({ - hookId, - hookName, - hookEvent, - getOutput: async () => ({ stdout, stderr, output }), - }) - - // Wait for stdout and stderr streams to finish before considering output complete - // This prevents a race condition where 'close' fires before all 'data' events are processed - const stdoutEndPromise = new Promise(resolve => { - child.stdout.on('end', () => resolve()) - }) - - const stderrEndPromise = new Promise(resolve => { - child.stderr.on('end', () => resolve()) - }) - - // Write to stdin, making sure to handle EPIPE errors that can happen when - // the hook command exits before reading all input. - // Note: EPIPE handling is difficult to set up in testing since Bun and Node - // have different behaviors. - // TODO: Add tests for EPIPE handling. - // Skip if stdin was already written (e.g., by config-based async hook path) - const stdinWritePromise = stdinWritten - ? Promise.resolve() - : new Promise((resolve, reject) => { - child.stdin.on('error', err => { - // When requestPrompt is provided, stdin stays open for prompt responses. - // EPIPE errors from later writes (after process exits) are expected -- suppress them. - if (!requestPrompt) { - reject(err) - } else { - logForDebugging( - `Hooks: stdin error during prompt flow (likely process exited): ${err}`, - ) - } - }) - // Explicitly specify UTF-8 encoding to ensure proper handling of Unicode characters - child.stdin.write(jsonInput + '\n', 'utf8') - // When requestPrompt is provided, keep stdin open for prompt responses - if (!requestPrompt) { - child.stdin.end() - } - resolve() - }) - - // Create promise for child process error - const childErrorPromise = new Promise((_, reject) => { - child.on('error', reject) - }) - - // Create promise for child process close - but only resolve after streams end - // to ensure all output has been collected - const childClosePromise = new Promise<{ - stdout: string - stderr: string - output: string - status: number - aborted?: boolean - }>(resolve => { - let exitCode: number | null = null - - child.on('close', code => { - exitCode = code ?? 1 - - // Wait for both streams to end before resolving with the final output - void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => { - // Strip lines we processed as prompt requests so parseHookOutput - // only sees the final hook result. Content-matching against the set - // of actually-processed lines means prompt JSON can never leak - // through (fail-closed), regardless of line positioning. - const finalStdout = - processedPromptLines.size === 0 - ? stdout - : stdout - .split('\n') - .filter(line => !processedPromptLines.has(line.trim())) - .join('\n') - - resolve({ - stdout: finalStdout, - stderr, - output, - status: exitCode!, - aborted: signal.aborted, - }) - }) - }) - }) - - // Race between stdin write, async detection, and process completion - try { - if (shouldEmitDiag) { - logForDiagnosticsNoPII('info', 'hook_spawn_started', { - hook_event_name: hookEvent, - index: hookIndex, - }) - } - await Promise.race([stdinWritePromise, childErrorPromise]) - - // Wait for any pending prompt responses before resolving - const result = await Promise.race([ - childIsAsyncPromise, - childClosePromise, - childErrorPromise, - ]) - // Ensure all queued prompt responses have been sent - await promptChain - diagExitCode = result.status - diagAborted = result.aborted ?? false - return result - } catch (error) { - // Handle errors from stdin write or child process - const code = getErrnoCode(error) - diagExitCode = 1 - - if (code === 'EPIPE') { - logForDebugging( - 'EPIPE error while writing to hook stdin (hook command likely closed early)', - ) - const errMsg = - 'Hook command closed stdin before hook input was fully written (EPIPE)' - return { - stdout: '', - stderr: errMsg, - output: errMsg, - status: 1, - } - } else if (code === 'ABORT_ERR') { - diagAborted = true - return { - stdout: '', - stderr: 'Hook cancelled', - output: 'Hook cancelled', - status: 1, - aborted: true, - } - } else { - const errorMsg = errorMessage(error) - const errOutput = `Error occurred while executing hook command: ${errorMsg}` - return { - stdout: '', - stderr: errOutput, - output: errOutput, - status: 1, - } - } - } finally { - if (shouldEmitDiag) { - logForDiagnosticsNoPII('info', 'hook_spawn_completed', { - hook_event_name: hookEvent, - index: hookIndex, - duration_ms: Date.now() - diagStartMs, - exit_code: diagExitCode, - aborted: diagAborted, - }) - } - stopProgressInterval() - // Clean up stream resources unless ownership was transferred (e.g., to async hook registry) - if (!shellCommandTransferred) { - shellCommand.cleanup() - } - } -} - -/** - * Check if a match query matches a hook matcher pattern - * @param matchQuery The query to match (e.g., 'Write', 'Edit', 'Bash') - * @param matcher The matcher pattern - can be: - * - Simple string for exact match (e.g., 'Write') - * - Pipe-separated list for multiple exact matches (e.g., 'Write|Edit') - * - Regex pattern (e.g., '^Write.*', '.*', '^(Write|Edit)$') - * @returns true if the query matches the pattern - */ -function matchesPattern(matchQuery: string, matcher: string): boolean { - if (!matcher || matcher === '*') { - return true - } - // Check if it's a simple string or pipe-separated list (no regex special chars except |) - if (/^[a-zA-Z0-9_|]+$/.test(matcher)) { - // Handle pipe-separated exact matches - if (matcher.includes('|')) { - const patterns = matcher - .split('|') - .map(p => normalizeLegacyToolName(p.trim())) - return patterns.includes(matchQuery) - } - // Simple exact match - return matchQuery === normalizeLegacyToolName(matcher) - } - - // Otherwise treat as regex - try { - const regex = new RegExp(matcher) - if (regex.test(matchQuery)) { - return true - } - // Also test against legacy names so patterns like "^Task$" still match - for (const legacyName of getLegacyToolNames(matchQuery)) { - if (regex.test(legacyName)) { - return true - } - } - return false - } catch { - // If the regex is invalid, log error and return false - logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`) - return false - } -} - -type IfConditionMatcher = (ifCondition: string) => boolean - -/** - * Prepare a matcher for hook `if` conditions. Expensive work (tool lookup, - * Zod validation, tree-sitter parsing for Bash) happens once here; the - * returned closure is called per hook. Returns undefined for non-tool events. - */ -async function prepareIfConditionMatcher( - hookInput: HookInput, - tools: Tools | undefined, -): Promise { - if ( - hookInput.hook_event_name !== 'PreToolUse' && - hookInput.hook_event_name !== 'PostToolUse' && - hookInput.hook_event_name !== 'PostToolUseFailure' && - hookInput.hook_event_name !== 'PermissionRequest' - ) { - return undefined - } - - const toolName = normalizeLegacyToolName(hookInput.tool_name) - const tool = tools && findToolByName(tools, hookInput.tool_name) - const input = tool?.inputSchema.safeParse(hookInput.tool_input) - const patternMatcher = - input?.success && tool?.preparePermissionMatcher - ? await tool.preparePermissionMatcher(input.data) - : undefined - - return ifCondition => { - const parsed = permissionRuleValueFromString(ifCondition) - if (normalizeLegacyToolName(parsed.toolName) !== toolName) { - return false - } - if (!parsed.ruleContent) { - return true - } - return patternMatcher ? patternMatcher(parsed.ruleContent) : false - } -} - -type FunctionHookMatcher = { - matcher: string - hooks: FunctionHook[] -} - -/** - * A hook paired with optional plugin context. - * Used when returning matched hooks so we can apply plugin env vars at execution time. - */ -type MatchedHook = { - hook: HookCommand | HookCallback | FunctionHook - pluginRoot?: string - pluginId?: string - skillRoot?: string - hookSource?: string -} - -function isInternalHook(matched: MatchedHook): boolean { - return matched.hook.type === 'callback' && matched.hook.internal === true -} - -/** - * Build a dedup key for a matched hook, namespaced by source context. - * - * Settings-file hooks (no pluginRoot/skillRoot) share the '' prefix so the - * same command defined in user/project/local still collapses to one 鈥?the - * original intent of the dedup. Plugin/skill hooks get their root as the - * prefix, so two plugins sharing an unexpanded `${CLAUDE_PLUGIN_ROOT}/hook.sh` - * template don't collapse: after expansion they point to different files. - */ -function hookDedupKey(m: MatchedHook, payload: string): string { - return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}` -} - -/** - * Build a map of {sanitizedPluginName: hookCount} from matched hooks. - * Only logs actual names for official marketplace plugins; others become 'third-party'. - */ -function getPluginHookCounts( - hooks: MatchedHook[], -): Record | undefined { - const pluginHooks = hooks.filter(h => h.pluginId) - if (pluginHooks.length === 0) { - return undefined - } - const counts: Record = {} - for (const h of pluginHooks) { - const atIndex = h.pluginId!.lastIndexOf('@') - const isOfficial = - atIndex > 0 && - ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1)) - const key = isOfficial ? h.pluginId! : 'third-party' - counts[key] = (counts[key] || 0) + 1 - } - return counts -} - - -/** - * Build a map of {hookType: count} from matched hooks. - */ -function getHookTypeCounts(hooks: MatchedHook[]): Record { - const counts: Record = {} - for (const h of hooks) { - counts[h.hook.type] = (counts[h.hook.type] || 0) + 1 - } - return counts -} - -function getHooksConfig( - appState: AppState | undefined, - sessionId: string, - hookEvent: HookEvent, -): Array< - | HookMatcher - | HookCallbackMatcher - | FunctionHookMatcher - | PluginHookMatcher - | SkillHookMatcher - | SessionDerivedHookMatcher -> { - // HookMatcher is a zod-stripped {matcher, hooks} so snapshot matchers can be - // pushed directly without re-wrapping. - const hooks: Array< - | HookMatcher - | HookCallbackMatcher - | FunctionHookMatcher - | PluginHookMatcher - | SkillHookMatcher - | SessionDerivedHookMatcher - > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])] - - // Check if only managed hooks should run (used for both registered and session hooks) - const managedOnly = shouldAllowManagedHooksOnly() - - // Process registered hooks (SDK callbacks and plugin native hooks) - const registeredHooks = getRegisteredHooks()?.[hookEvent] - if (registeredHooks) { - for (const matcher of registeredHooks) { - // Skip plugin hooks when restricted to managed hooks only - // Plugin hooks have pluginRoot set, SDK callbacks do not - if (managedOnly && 'pluginRoot' in matcher) { - continue - } - hooks.push(matcher) - } - } - - // Merge session hooks for the current session only - // Function hooks (like structured output enforcement) must be scoped to their session - // to prevent hooks from one agent leaking to another (e.g., verification agent to main agent) - // Skip session hooks entirely when allowManagedHooksOnly is set 鈥? - // this prevents frontmatter hooks from agents/skills from bypassing the policy. - // strictPluginOnlyCustomization does NOT block here 鈥?it gates at the - // REGISTRATION sites (runAgent.ts:526 for agent frontmatter hooks) where - // agentDefinition.source is known. A blanket block here would also kill - // plugin-provided agents' frontmatter hooks, which is too broad. - // Also skip if appState not provided (for backwards compatibility) - if (!managedOnly && appState !== undefined) { - const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get( - hookEvent, - ) - if (sessionHooks) { - // SessionDerivedHookMatcher already includes optional skillRoot - for (const matcher of sessionHooks) { - hooks.push(matcher) - } - } - - // Merge session function hooks separately (can't be persisted to HookMatcher format) - const sessionFunctionHooks = getSessionFunctionHooks( - appState, - sessionId, - hookEvent, - ).get(hookEvent) - if (sessionFunctionHooks) { - for (const matcher of sessionFunctionHooks) { - hooks.push(matcher) - } - } - } - - return hooks -} - -/** - * Lightweight existence check for hooks on a given event. Mirrors the sources - * assembled by getHooksConfig() but stops at the first hit without building - * the full merged config. - * - * Intentionally over-approximates: returns true if any matcher exists for the - * event, even if managed-only filtering or pattern matching would later - * discard it. A false positive just means we proceed to the full matching - * path; a false negative would skip a hook, so we err on the side of true. - * - * Used to skip createBaseHookInput (getTranscriptPathForSession path joins) - * and getMatchingHooks on hot paths where hooks are typically unconfigured. - * See hasInstructionsLoadedHook / hasWorktreeCreateHook for the same pattern. - */ -function hasHookForEvent( - hookEvent: HookEvent, - appState: AppState | undefined, - sessionId: string, -): boolean { - const snap = getHooksConfigFromSnapshot()?.[hookEvent] - if (snap && snap.length > 0) return true - const reg = getRegisteredHooks()?.[hookEvent] - if (reg && reg.length > 0) return true - if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true - return false -} - -/** - * Get hook commands that match the given query - * @param appState The current app state (optional for backwards compatibility) - * @param sessionId The current session ID (main session or agent ID) - * @param hookEvent The hook event - * @param hookInput The hook input for matching - * @returns Array of matched hooks with optional plugin context - */ -export async function getMatchingHooks( - appState: AppState | undefined, - sessionId: string, - hookEvent: HookEvent, - hookInput: HookInput, - tools?: Tools, -): Promise { - try { - const hookMatchers = getHooksConfig(appState, sessionId, hookEvent) - - // If you change the criteria below, then you must change - // src/utils/hooks/hooksConfigManager.ts as well. - let matchQuery: string | undefined = undefined - switch (hookInput.hook_event_name) { - case 'PreToolUse': - case 'PostToolUse': - case 'PostToolUseFailure': - case 'PermissionRequest': - case 'PermissionDenied': - matchQuery = hookInput.tool_name - break - case 'SessionStart': - matchQuery = hookInput.source - break - case 'Setup': - matchQuery = hookInput.trigger - break - case 'PreCompact': - case 'PostCompact': - matchQuery = hookInput.trigger - break - case 'Notification': - matchQuery = hookInput.notification_type - break - case 'SessionEnd': - matchQuery = hookInput.reason - break - case 'StopFailure': - matchQuery = hookInput.error - break - case 'SubagentStart': - matchQuery = hookInput.agent_type - break - case 'SubagentStop': - matchQuery = hookInput.agent_type - break - case 'TeammateIdle': - case 'TaskCreated': - case 'TaskCompleted': - break - case 'Elicitation': - matchQuery = hookInput.mcp_server_name - break - case 'ElicitationResult': - matchQuery = hookInput.mcp_server_name - break - case 'ConfigChange': - matchQuery = hookInput.source - break - case 'InstructionsLoaded': - matchQuery = hookInput.load_reason - break - case 'FileChanged': - matchQuery = basename(hookInput.file_path) - break - default: - break - } - - logForDebugging( - `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`, - { level: 'verbose' }, - ) - logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, { - level: 'verbose', - }) - - // Extract hooks with their plugin context (if any) - const filteredMatchers = matchQuery - ? hookMatchers.filter( - matcher => - !matcher.matcher || matchesPattern(matchQuery, matcher.matcher), - ) - : hookMatchers - - const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => { - // Check if this is a PluginHookMatcher (has pluginRoot) or SkillHookMatcher (has skillRoot) - const pluginRoot = - 'pluginRoot' in matcher ? matcher.pluginRoot : undefined - const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined - const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined - const hookSource = pluginRoot - ? 'pluginName' in matcher - ? `plugin:${matcher.pluginName}` - : 'plugin' - : skillRoot - ? 'skillName' in matcher - ? `skill:${matcher.skillName}` - : 'skill' - : 'settings' - return matcher.hooks.map(hook => ({ - hook, - pluginRoot, - pluginId, - skillRoot, - hookSource, - })) - }) - - // Deduplicate hooks by command/prompt/url within the same source context. - // Key is namespaced by pluginRoot/skillRoot (see hookDedupKey above) so - // cross-plugin template collisions don't drop hooks (gh-29724). - // - // Note: new Map(entries) keeps the LAST entry on key collision, not first. - // For settings hooks this means the last-merged scope wins; for - // same-plugin duplicates the pluginRoot is identical so it doesn't matter. - // Fast-path: callback/function hooks don't need dedup (each is unique). - // Skip the 6-pass filter + 4脳Map + 4脳Array.from below when all hooks are - // callback/function 鈥?the common case for internal hooks like - // sessionFileAccessHooks/attributionHooks (44x faster in microbench). - if ( - matchedHooks.every( - m => m.hook.type === 'callback' || m.hook.type === 'function', - ) - ) { - return matchedHooks - } - - // Helper to extract the `if` condition from a hook for dedup keys. - // Hooks with different `if` conditions are distinct even if otherwise identical. - const getIfCondition = (hook: { if?: string }): string => hook.if ?? '' - - const uniqueCommandHooks = Array.from( - new Map( - matchedHooks - .filter( - ( - m, - ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } => - m.hook.type === 'command', - ) - // shell is part of identity: {command:'echo x', shell:'bash'} - // and {command:'echo x', shell:'powershell'} are distinct hooks, - // not duplicates. Default to 'bash' so legacy configs (no shell - // field) still dedup against explicit shell:'bash'. - .map(m => [ - hookDedupKey( - m, - `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`, - ), - m, - ]), - ).values(), - ) - const uniquePromptHooks = Array.from( - new Map( - matchedHooks - .filter(m => m.hook.type === 'prompt') - .map(m => [ - hookDedupKey( - m, - `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, - ), - m, - ]), - ).values(), - ) - const uniqueAgentHooks = Array.from( - new Map( - matchedHooks - .filter(m => m.hook.type === 'agent') - .map(m => [ - hookDedupKey( - m, - `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, - ), - m, - ]), - ).values(), - ) - const uniqueHttpHooks = Array.from( - new Map( - matchedHooks - .filter(m => m.hook.type === 'http') - .map(m => [ - hookDedupKey( - m, - `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`, - ), - m, - ]), - ).values(), - ) - const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback') - // Function hooks don't need deduplication - each callback is unique - const functionHooks = matchedHooks.filter(m => m.hook.type === 'function') - const uniqueHooks = [ - ...uniqueCommandHooks, - ...uniquePromptHooks, - ...uniqueAgentHooks, - ...uniqueHttpHooks, - ...callbackHooks, - ...functionHooks, - ] - - // Filter hooks based on their `if` condition. This allows hooks to specify - // conditions like "Bash(git *)" to only run for git commands, avoiding - // process spawning overhead for non-matching commands. - const hasIfCondition = uniqueHooks.some( - h => - (h.hook.type === 'command' || - h.hook.type === 'prompt' || - h.hook.type === 'agent' || - h.hook.type === 'http') && - (h.hook as { if?: string }).if, - ) - const ifMatcher = hasIfCondition - ? await prepareIfConditionMatcher(hookInput, tools) - : undefined - const ifFilteredHooks = uniqueHooks.filter(h => { - if ( - h.hook.type !== 'command' && - h.hook.type !== 'prompt' && - h.hook.type !== 'agent' && - h.hook.type !== 'http' - ) { - return true - } - const ifCondition = (h.hook as { if?: string }).if - if (!ifCondition) { - return true - } - if (!ifMatcher) { - logForDebugging( - `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`, - ) - return false - } - if (ifMatcher(ifCondition)) { - return true - } - logForDebugging( - `Skipping hook due to if condition "${ifCondition}" not matching`, - ) - return false - }) - - // HTTP hooks are not supported for SessionStart/Setup events. In headless - // mode the sandbox ask callback deadlocks because the structuredInput - // consumer hasn't started yet when these hooks fire. - const filteredHooks = - hookEvent === 'SessionStart' || hookEvent === 'Setup' - ? ifFilteredHooks.filter(h => { - if (h.hook.type === 'http') { - logForDebugging( - `Skipping HTTP hook ${(h.hook as { url: string }).url} 鈥?HTTP hooks are not supported for ${hookEvent}`, - ) - return false - } - return true - }) - : ifFilteredHooks - - logForDebugging( - `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`, - { level: 'verbose' }, - ) - return filteredHooks - } catch { - return [] - } -} - -/** - * Format a list of blocking errors from a PreTool hook's configured commands. - * @param hookName The name of the hook (e.g., 'PreToolUse:Write', 'PreToolUse:Edit', 'PreToolUse:Bash') - * @param blockingErrors Array of blocking errors from hooks - * @returns Formatted blocking message - */ -export function getPreToolHookBlockingMessage( - hookName: string, - blockingError: HookBlockingError, -): string { - return `${hookName} hook error: ${blockingError.blockingError}` -} - -/** - * Format a list of blocking errors from a Stop hook's configured commands. - * @param blockingErrors Array of blocking errors from hooks - * @returns Formatted message to give feedback to the model - */ -export function getStopHookMessage(blockingError: HookBlockingError): string { - return `Stop hook feedback:\n${blockingError.blockingError}` -} - -/** - * Format a blocking error from a TeammateIdle hook. - * @param blockingError The blocking error from the hook - * @returns Formatted message to give feedback to the model - */ -export function getTeammateIdleHookMessage( - blockingError: HookBlockingError, -): string { - return `TeammateIdle hook feedback:\n${blockingError.blockingError}` -} - -/** - * Format a blocking error from a TaskCreated hook. - * @param blockingError The blocking error from the hook - * @returns Formatted message to give feedback to the model - */ -export function getTaskCreatedHookMessage( - blockingError: HookBlockingError, -): string { - return `TaskCreated hook feedback:\n${blockingError.blockingError}` -} - -/** - * Format a blocking error from a TaskCompleted hook. - * @param blockingError The blocking error from the hook - * @returns Formatted message to give feedback to the model - */ -export function getTaskCompletedHookMessage( - blockingError: HookBlockingError, -): string { - return `TaskCompleted hook feedback:\n${blockingError.blockingError}` -} - -/** - * Format a list of blocking errors from a UserPromptSubmit hook's configured commands. - * @param blockingErrors Array of blocking errors from hooks - * @returns Formatted blocking message - */ -export function getUserPromptSubmitHookBlockingMessage( - blockingError: HookBlockingError, -): string { - return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}` -} -/** - * Common logic for executing hooks - * @param hookInput The structured hook input that will be validated and converted to JSON - * @param toolUseID The ID for tracking this hook execution - * @param matchQuery The query to match against hook matchers - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @param toolUseContext Optional ToolUseContext for prompt-based hooks (required if using prompt hooks) - * @param messages Optional conversation history for prompt/function hooks - * @returns Async generator that yields progress messages and hook results - */ -async function* executeHooks({ - hookInput, - toolUseID, - matchQuery, - signal, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - toolUseContext, - messages, - forceSyncExecution, - requestPrompt, - toolInputSummary, -}: { - hookInput: HookInput - toolUseID: string - matchQuery?: string - signal?: AbortSignal - timeoutMs?: number - toolUseContext?: ToolUseContext - messages?: Message[] - forceSyncExecution?: boolean - requestPrompt?: ( - sourceName: string, - toolInputSummary?: string | null, - ) => (request: PromptRequest) => Promise - toolInputSummary?: string | null -}): AsyncGenerator { - if (shouldDisableAllHooksIncludingManaged()) { - return - } - - if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { - return - } - - const hookEvent = hookInput.hook_event_name - const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent - - // Bind the prompt callback to this hook's name and tool input summary so the UI can display context - const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary) - - // SECURITY: ALL hooks require workspace trust in interactive mode - // This centralized check prevents RCE vulnerabilities for all current and future hooks - if (shouldSkipHookDueToTrust()) { - logForDebugging( - `Skipping ${hookName} hook execution - workspace trust not accepted`, - ) - return - } - - const appState = toolUseContext ? toolUseContext.getAppState() : undefined - // Use the agent's session ID if available, otherwise fall back to main session - const sessionId = toolUseContext?.agentId ?? getSessionId() - const matchingHooks = await getMatchingHooks( - appState, - sessionId, - hookEvent, - hookInput, - toolUseContext?.options?.tools, - ) - if (matchingHooks.length === 0) { - return - } - - if (signal?.aborted) { - return - } - - const userHooks = matchingHooks.filter(h => !isInternalHook(h)) - if (userHooks.length > 0) { - const pluginHookCounts = getPluginHookCounts(userHooks) - const hookTypeCounts = getHookTypeCounts(userHooks) - logEvent(`tengu_run_hook`, { - hookName: - hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numCommands: userHooks.length, - hookTypeCounts: jsonStringify( - hookTypeCounts, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(pluginHookCounts && { - pluginHookCounts: jsonStringify( - pluginHookCounts, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - }) - } else { - // Fast-path: all hooks are internal callbacks (sessionFileAccessHooks, - // attributionHooks). These return {} and don't use the abort signal, so we - // can skip span/progress/abortSignal/processHookJSONOutput/resultLoop. - // Measured: 6.01碌s 鈫?~1.8碌s per PostToolUse hit (-70%). - const batchStartTime = Date.now() - const context = toolUseContext - ? { - getAppState: toolUseContext.getAppState, - updateAttributionState: toolUseContext.updateAttributionState, - } - : undefined - for (const [i, { hook }] of matchingHooks.entries()) { - if (hook.type === 'callback') { - await hook.callback(hookInput, toolUseID, signal, i, context) - } - } - const totalDurationMs = Date.now() - batchStartTime - getStatsStore()?.observe('hook_duration_ms', totalDurationMs) - addToTurnHookDuration(totalDurationMs) - logEvent(`tengu_repl_hook_finished`, { - hookName: - hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numCommands: matchingHooks.length, - numSuccess: matchingHooks.length, - numBlocking: 0, - numNonBlockingError: 0, - numCancelled: 0, - totalDurationMs, - }) - return - } - - // Collect hook definitions for beta tracing telemetry - const hookDefinitionsJson = isBetaTracingEnabled() - ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks)) - : '[]' - - // Log hook execution start to OTEL (only for beta tracing) - if (isBetaTracingEnabled()) { - void logOTelEvent('hook_execution_start', { - hook_event: hookEvent, - hook_name: hookName, - num_hooks: String(matchingHooks.length), - managed_only: String(shouldAllowManagedHooksOnly()), - hook_definitions: hookDefinitionsJson, - hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', - }) - } - - // Start hook span for beta tracing - const hookSpan = startHookSpan( - hookEvent, - hookName, - matchingHooks.length, - hookDefinitionsJson, - ) - - // Yield progress messages for each hook before execution - for (const { hook } of matchingHooks) { - yield { - message: { - type: 'progress', - data: { - type: 'hook_progress', - hookEvent, - hookName, - command: getHookDisplayText(hook), - ...(hook.type === 'prompt' && { promptText: hook.prompt }), - ...('statusMessage' in hook && - hook.statusMessage != null && { - statusMessage: hook.statusMessage, - }), - }, - parentToolUseID: toolUseID, - toolUseID, - timestamp: new Date().toISOString(), - uuid: randomUUID(), - }, - } - } - - // Track wall-clock time for the entire hook batch - const batchStartTime = Date.now() - - // Lazy-once stringify of hookInput. Shared across all command/prompt/agent/http - // hooks in this batch (hookInput is never mutated). Callback/function hooks - // return before reaching this, so batches with only those pay no stringify cost. - let jsonInputResult: - | { ok: true; value: string } - | { ok: false; error: unknown } - | undefined - function getJsonInput() { - if (jsonInputResult !== undefined) { - return jsonInputResult - } - try { - return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) }) - } catch (error) { - logError( - Error(`Failed to stringify hook ${hookName} input`, { cause: error }), - ) - return (jsonInputResult = { ok: false, error }) - } - } - - // Run all hooks in parallel with individual timeouts - const hookPromises = matchingHooks.map(async function* ( - { hook, pluginRoot, pluginId, skillRoot }, - hookIndex, - ): AsyncGenerator { - if (hook.type === 'callback') { - const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs - const { signal: abortSignal, cleanup } = createCombinedAbortSignal( - signal, - { timeoutMs: callbackTimeoutMs }, - ) - yield executeHookCallback({ - toolUseID, - hook, - hookEvent, - hookInput, - signal: abortSignal, - hookIndex, - toolUseContext, - }).finally(cleanup) - return - } - - if (hook.type === 'function') { - if (!messages) { - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - hookName, - toolUseID, - hookEvent, - content: 'Messages not provided for function hook', - }), - outcome: 'non_blocking_error', - hook, - } - return - } - - // Function hooks only come from session storage with callback embedded - yield executeFunctionHook({ - hook, - messages, - hookName, - toolUseID, - hookEvent, - timeoutMs, - signal, - }) - return - } - - // Command and prompt hooks need jsonInput - const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs - const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { - timeoutMs: commandTimeoutMs, - }) - const hookId = randomUUID() - const hookStartMs = Date.now() - const hookCommand = getHookDisplayText(hook) - - try { - const jsonInputRes = getJsonInput() - if (!jsonInputRes.ok) { - yield { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - hookName, - toolUseID, - hookEvent, - content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`, - command: hookCommand, - durationMs: Date.now() - hookStartMs, - }), - outcome: 'non_blocking_error', - hook, - } - cleanup() - return - } - const jsonInput = jsonInputRes.value - - if (hook.type === 'prompt') { - if (!toolUseContext) { - throw new Error( - 'ToolUseContext is required for prompt hooks. This is a bug.', - ) - } - const promptResult = await execPromptHook( - hook, - hookName, - hookEvent, - jsonInput, - abortSignal, - toolUseContext, - messages, - toolUseID, - ) - // Inject timing fields for hook visibility - if (promptResult.message?.type === 'attachment') { - const att = promptResult.message.attachment - if ( - att.type === 'hook_success' || - att.type === 'hook_non_blocking_error' - ) { - att.command = hookCommand - att.durationMs = Date.now() - hookStartMs - } - } - yield promptResult - cleanup?.() - return - } - - if (hook.type === 'agent') { - if (!toolUseContext) { - throw new Error( - 'ToolUseContext is required for agent hooks. This is a bug.', - ) - } - if (!messages) { - throw new Error( - 'Messages are required for agent hooks. This is a bug.', - ) - } - const agentResult = await execAgentHook( - hook, - hookName, - hookEvent, - jsonInput, - abortSignal, - toolUseContext, - toolUseID, - messages, - 'agent_type' in hookInput - ? (hookInput.agent_type as string) - : undefined, - ) - // Inject timing fields for hook visibility - if (agentResult.message?.type === 'attachment') { - const att = agentResult.message.attachment - if ( - att.type === 'hook_success' || - att.type === 'hook_non_blocking_error' - ) { - att.command = hookCommand - att.durationMs = Date.now() - hookStartMs - } - } - yield agentResult - cleanup?.() - return - } - - if (hook.type === 'http') { - emitHookStarted(hookId, hookName, hookEvent) - - // execHttpHook manages its own timeout internally via hook.timeout or - // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so pass the parent signal directly - // to avoid double-stacking timeouts with abortSignal. - const httpResult = await execHttpHook( - hook, - hookEvent, - jsonInput, - signal, - ) - cleanup?.() - - if (httpResult.aborted) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: 'Hook cancelled', - stdout: '', - stderr: '', - exitCode: undefined, - outcome: 'cancelled', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName, - toolUseID, - hookEvent, - }), - outcome: 'cancelled' as const, - hook, - } - return - } - - if (httpResult.error || !httpResult.ok) { - const stderr = - httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: stderr, - stdout: '', - stderr, - exitCode: httpResult.statusCode, - outcome: 'error', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID, - hookEvent, - stderr, - stdout: '', - exitCode: httpResult.statusCode ?? 0, - }), - outcome: 'non_blocking_error' as const, - hook, - } - return - } - - // HTTP hooks must return JSON 鈥?parse and validate through Zod - const { json: httpJson, validationError: httpValidationError } = - parseHttpHookOutput(httpResult.body) - - if (httpValidationError) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: httpResult.body, - stdout: httpResult.body, - stderr: `JSON validation failed: ${httpValidationError}`, - exitCode: httpResult.statusCode, - outcome: 'error', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID, - hookEvent, - stderr: `JSON validation failed: ${httpValidationError}`, - stdout: httpResult.body, - exitCode: httpResult.statusCode ?? 0, - }), - outcome: 'non_blocking_error' as const, - hook, - } - return - } - - if (httpJson && isAsyncHookJSONOutput(httpJson)) { - // Async response: treat as success (no further processing) - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: httpResult.body, - stdout: httpResult.body, - stderr: '', - exitCode: httpResult.statusCode, - outcome: 'success', - }) - yield { - outcome: 'success' as const, - hook, - } - return - } - - if (httpJson) { - const processed = processHookJSONOutput({ - json: httpJson, - command: hook.url, - hookName, - toolUseID, - hookEvent, - expectedHookEvent: hookEvent, - stdout: httpResult.body, - stderr: '', - exitCode: httpResult.statusCode, - }) - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: httpResult.body, - stdout: httpResult.body, - stderr: '', - exitCode: httpResult.statusCode, - outcome: 'success', - }) - yield { - ...processed, - outcome: 'success' as const, - hook, - } - return - } - - return - } - - emitHookStarted(hookId, hookName, hookEvent) - - const result = await execCommandHook( - hook, - hookEvent, - hookName, - jsonInput, - abortSignal, - hookId, - hookIndex, - pluginRoot, - pluginId, - skillRoot, - forceSyncExecution, - boundRequestPrompt, - ) - cleanup?.() - const durationMs = Date.now() - hookStartMs - - if (result.backgrounded) { - yield { - outcome: 'success' as const, - hook, - } - return - } - - if (result.aborted) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: 'cancelled', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_cancelled', - hookName, - toolUseID, - hookEvent, - command: hookCommand, - durationMs, - }), - outcome: 'cancelled' as const, - hook, - } - return - } - - // Try JSON parsing first - const { json, plainText, validationError } = parseHookOutput( - result.stdout, - ) - - if (validationError) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: `JSON validation failed: ${validationError}`, - exitCode: 1, - outcome: 'error', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID, - hookEvent, - stderr: `JSON validation failed: ${validationError}`, - stdout: result.stdout, - exitCode: 1, - command: hookCommand, - durationMs, - }), - outcome: 'non_blocking_error' as const, - hook, - } - return - } - - if (json) { - // Async responses were already backgrounded during execution - if (isAsyncHookJSONOutput(json)) { - yield { - outcome: 'success' as const, - hook, - } - return - } - - // Process JSON output - const processed = processHookJSONOutput({ - json, - command: hookCommand, - hookName, - toolUseID, - hookEvent, - expectedHookEvent: hookEvent, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - durationMs, - }) - - // Handle suppressOutput (skip for async responses) - if ( - isSyncHookJSONOutput(json) && - !json.suppressOutput && - plainText && - result.status === 0 - ) { - // Still show non-JSON output if not suppressed - const content = `${chalk.bold(hookName)} completed` - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: 'success', - }) - yield { - ...processed, - message: - processed.message || - createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID, - hookEvent, - content, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - command: hookCommand, - durationMs, - }), - outcome: 'success' as const, - hook, - } - return - } - - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: result.status === 0 ? 'success' : 'error', - }) - yield { - ...processed, - outcome: 'success' as const, - hook, - } - return - } - - // Fall back to existing logic for non-JSON output - if (result.status === 0) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: 'success', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID, - hookEvent, - content: result.stdout.trim(), - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - command: hookCommand, - durationMs, - }), - outcome: 'success' as const, - hook, - } - return - } - - // Hooks with exit code 2 provide blocking feedback - if (result.status === 2) { - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: 'error', - }) - yield { - blockingError: { - blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`, - command: hook.command, - }, - outcome: 'blocking' as const, - hook, - } - return - } - - // Any other non-zero exit code is a non-critical error that should just - // be shown to the user. - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: result.output, - stdout: result.stdout, - stderr: result.stderr, - exitCode: result.status, - outcome: 'error', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID, - hookEvent, - stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`, - stdout: result.stdout, - exitCode: result.status, - command: hookCommand, - durationMs, - }), - outcome: 'non_blocking_error' as const, - hook, - } - return - } catch (error) { - // Clean up on error - cleanup?.() - - const errorMessage = - error instanceof Error ? error.message : String(error) - emitHookResponse({ - hookId, - hookName, - hookEvent, - output: `Failed to run: ${errorMessage}`, - stdout: '', - stderr: `Failed to run: ${errorMessage}`, - exitCode: 1, - outcome: 'error', - }) - yield { - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID, - hookEvent, - stderr: `Failed to run: ${errorMessage}`, - stdout: '', - exitCode: 1, - command: hookCommand, - durationMs: Date.now() - hookStartMs, - }), - outcome: 'non_blocking_error' as const, - hook, - } - return - } - }) - - // Track outcomes for logging - const outcomes = { - success: 0, - blocking: 0, - non_blocking_error: 0, - cancelled: 0, - } - - let permissionBehavior: PermissionResult['behavior'] | undefined - - // Run all hooks in parallel and wait for all to complete - for await (const result of all(hookPromises)) { - outcomes[result.outcome]++ - - // Check for preventContinuation early - if (result.preventContinuation) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`, - ) - yield { - preventContinuation: true, - blockingError: result.blockingError, - stopReason: result.stopReason, - } - } - - // Handle different result types - if (result.blockingError) { - yield { - blockingError: result.blockingError, - } - } - - if (result.message) { - yield { message: result.message } - } - - // Yield system message separately if present - if (result.systemMessage) { - yield { - message: createAttachmentMessage({ - type: 'hook_system_message', - content: result.systemMessage, - hookName, - toolUseID, - hookEvent, - }), - } - } - - // Collect additional context from hooks - if (result.additionalContext) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`, - ) - yield { - additionalContexts: [result.additionalContext], - } - } - - if (result.initialUserMessage) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`, - ) - yield { - initialUserMessage: result.initialUserMessage, - } - } - - if (result.watchPaths && result.watchPaths.length > 0) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`, - ) - yield { - watchPaths: result.watchPaths, - } - } - - // Yield updatedMCPToolOutput if provided (from PostToolUse hooks) - if (result.updatedMCPToolOutput) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`, - ) - yield { - updatedMCPToolOutput: result.updatedMCPToolOutput, - } - } - - // Check for permission behavior with precedence: deny > ask > allow - if (result.permissionBehavior) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`, - ) - // Apply precedence rules - switch (result.permissionBehavior) { - case 'deny': - // deny always takes precedence - permissionBehavior = 'deny' - break - case 'ask': - // ask takes precedence over allow but not deny - if (permissionBehavior !== 'deny') { - permissionBehavior = 'ask' - } - break - case 'allow': - // allow only if no other behavior set - if (!permissionBehavior) { - permissionBehavior = 'allow' - } - break - case 'passthrough': - // passthrough doesn't set permission behavior - break - } - } - - // Yield permission behavior and updatedInput if provided (from allow or ask behavior) - if (permissionBehavior !== undefined) { - const updatedInput = - result.updatedInput && - (result.permissionBehavior === 'allow' || - result.permissionBehavior === 'ask') - ? result.updatedInput - : undefined - if (updatedInput) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`, - ) - } - yield { - permissionBehavior, - hookPermissionDecisionReason: result.hookPermissionDecisionReason, - hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource, - updatedInput, - } - } - - // Yield updatedInput separately for passthrough case (no permission decision) - // This allows hooks to modify input without making a permission decision - // Note: Check result.permissionBehavior (this hook's behavior), not the aggregated permissionBehavior - if (result.updatedInput && result.permissionBehavior === undefined) { - logForDebugging( - `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`, - ) - yield { - updatedInput: result.updatedInput, - } - } - // Yield permission request result if provided (from PermissionRequest hooks) - if (result.permissionRequestResult) { - yield { - permissionRequestResult: result.permissionRequestResult, - } - } - // Yield retry flag if provided (from PermissionDenied hooks) - if (result.retry) { - yield { - retry: result.retry, - } - } - // Yield elicitation response if provided (from Elicitation hooks) - if (result.elicitationResponse) { - yield { - elicitationResponse: result.elicitationResponse, - } - } - // Yield elicitation result response if provided (from ElicitationResult hooks) - if (result.elicitationResultResponse) { - yield { - elicitationResultResponse: result.elicitationResultResponse, - } - } - - // Invoke session hook callback if this is a command/prompt/function hook (not a callback hook) - if (appState && result.hook.type !== 'callback') { - const sessionId = getSessionId() - // Use empty string as matcher when matchQuery is undefined (e.g., for Stop hooks) - const matcher = matchQuery ?? '' - const hookEntry = getSessionHookCallback( - appState, - sessionId, - hookEvent, - matcher, - result.hook, - ) - // Invoke onHookSuccess only on success outcome - if (hookEntry?.onHookSuccess && result.outcome === 'success') { - try { - hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult) - } catch (error) { - logError( - Error('Session hook success callback failed', { cause: error }), - ) - } - } - } - } - - const totalDurationMs = Date.now() - batchStartTime - getStatsStore()?.observe('hook_duration_ms', totalDurationMs) - addToTurnHookDuration(totalDurationMs) - - logEvent(`tengu_repl_hook_finished`, { - hookName: - hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numCommands: matchingHooks.length, - numSuccess: outcomes.success, - numBlocking: outcomes.blocking, - numNonBlockingError: outcomes.non_blocking_error, - numCancelled: outcomes.cancelled, - totalDurationMs, - }) - - // Log hook execution completion to OTEL (only for beta tracing) - if (isBetaTracingEnabled()) { - const hookDefinitionsComplete = - getHookDefinitionsForTelemetry(matchingHooks) - - void logOTelEvent('hook_execution_complete', { - hook_event: hookEvent, - hook_name: hookName, - num_hooks: String(matchingHooks.length), - num_success: String(outcomes.success), - num_blocking: String(outcomes.blocking), - num_non_blocking_error: String(outcomes.non_blocking_error), - num_cancelled: String(outcomes.cancelled), - managed_only: String(shouldAllowManagedHooksOnly()), - hook_definitions: jsonStringify(hookDefinitionsComplete), - hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', - }) - } - - // End hook span for beta tracing - endHookSpan(hookSpan, { - numSuccess: outcomes.success, - numBlocking: outcomes.blocking, - numNonBlockingError: outcomes.non_blocking_error, - numCancelled: outcomes.cancelled, - }) -} - -export type HookOutsideReplResult = { - command: string - succeeded: boolean - output: string - blocked: boolean - watchPaths?: string[] - systemMessage?: string -} - -export function hasBlockingResult(results: HookOutsideReplResult[]): boolean { - return results.some(r => r.blocked) -} - -/** - * Execute hooks outside of the REPL (e.g. notifications, session end) - * - * Unlike executeHooks() which yields messages that are exposed to the model as - * system messages, this function only logs errors via logForDebugging (visible - * with --debug). Callers that need to surface errors to users should handle - * the returned results appropriately (e.g. executeSessionEndHooks writes to - * stderr during shutdown). - * - * @param getAppState Optional function to get the current app state (for session hooks) - * @param hookInput The structured hook input that will be validated and converted to JSON - * @param matchQuery The query to match against hook matchers - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Array of HookOutsideReplResult objects containing command, succeeded, and output - */ -async function executeHooksOutsideREPL({ - getAppState, - hookInput, - matchQuery, - signal, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -}: { - getAppState?: () => AppState - hookInput: HookInput - matchQuery?: string - signal?: AbortSignal - timeoutMs: number -}): Promise { - if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { - return [] - } - - const hookEvent = hookInput.hook_event_name - const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent - if (shouldDisableAllHooksIncludingManaged()) { - logForDebugging( - `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, - ) - return [] - } - - // SECURITY: ALL hooks require workspace trust in interactive mode - // This centralized check prevents RCE vulnerabilities for all current and future hooks - if (shouldSkipHookDueToTrust()) { - logForDebugging( - `Skipping ${hookName} hook execution - workspace trust not accepted`, - ) - return [] - } - - const appState = getAppState ? getAppState() : undefined - // Use main session ID for outside-REPL hooks - const sessionId = getSessionId() - const matchingHooks = await getMatchingHooks( - appState, - sessionId, - hookEvent, - hookInput, - ) - if (matchingHooks.length === 0) { - return [] - } - - if (signal?.aborted) { - return [] - } - - const userHooks = matchingHooks.filter(h => !isInternalHook(h)) - if (userHooks.length > 0) { - const pluginHookCounts = getPluginHookCounts(userHooks) - const hookTypeCounts = getHookTypeCounts(userHooks) - logEvent(`tengu_run_hook`, { - hookName: - hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numCommands: userHooks.length, - hookTypeCounts: jsonStringify( - hookTypeCounts, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(pluginHookCounts && { - pluginHookCounts: jsonStringify( - pluginHookCounts, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - }) - } - - // Validate and stringify the hook input - let jsonInput: string - try { - jsonInput = jsonStringify(hookInput) - } catch (error) { - logError(error) - return [] - } - - // Run all hooks in parallel with individual timeouts - const hookPromises = matchingHooks.map( - async ({ hook, pluginRoot, pluginId }, hookIndex) => { - // Handle callback hooks - if (hook.type === 'callback') { - const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs - const { signal: abortSignal, cleanup } = createCombinedAbortSignal( - signal, - { timeoutMs: callbackTimeoutMs }, - ) - - try { - const toolUseID = randomUUID() - const json = await hook.callback( - hookInput, - toolUseID, - abortSignal, - hookIndex, - ) - - cleanup?.() - - if (isAsyncHookJSONOutput(json)) { - logForDebugging( - `${hookName} [callback] returned async response, returning empty output`, - ) - return { - command: 'callback', - succeeded: true, - output: '', - blocked: false, - } - } - - const output = - hookEvent === 'WorktreeCreate' && - isSyncHookJSONOutput(json) && - json.hookSpecificOutput?.hookEventName === 'WorktreeCreate' - ? json.hookSpecificOutput.worktreePath - : json.systemMessage || '' - const blocked = - isSyncHookJSONOutput(json) && json.decision === 'block' - - logForDebugging(`${hookName} [callback] completed successfully`) - - return { - command: 'callback', - succeeded: true, - output, - blocked, - } - } catch (error) { - cleanup?.() - - const errorMessage = - error instanceof Error ? error.message : String(error) - logForDebugging( - `${hookName} [callback] failed to run: ${errorMessage}`, - { level: 'error' }, - ) - return { - command: 'callback', - succeeded: false, - output: errorMessage, - blocked: false, - } - } - } - - // TODO: Implement prompt stop hooks outside REPL - if (hook.type === 'prompt') { - return { - command: hook.prompt, - succeeded: false, - output: 'Prompt stop hooks are not yet supported outside REPL', - blocked: false, - } - } - - // TODO: Implement agent stop hooks outside REPL - if (hook.type === 'agent') { - return { - command: hook.prompt, - succeeded: false, - output: 'Agent stop hooks are not yet supported outside REPL', - blocked: false, - } - } - - // Function hooks require messages array (only available in REPL context) - // For -p mode Stop hooks, use executeStopHooks which supports function hooks - if (hook.type === 'function') { - logError( - new Error( - `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`, - ), - ) - return { - command: 'function', - succeeded: false, - output: 'Internal error: function hook executed outside REPL context', - blocked: false, - } - } - - // Handle HTTP hooks (no toolUseContext needed - just HTTP POST). - // execHttpHook handles its own timeout internally via hook.timeout or - // DEFAULT_HTTP_HOOK_TIMEOUT_MS, so we pass signal directly. - if (hook.type === 'http') { - try { - const httpResult = await execHttpHook( - hook, - hookEvent, - jsonInput, - signal, - ) - - if (httpResult.aborted) { - logForDebugging(`${hookName} [${hook.url}] cancelled`) - return { - command: hook.url, - succeeded: false, - output: 'Hook cancelled', - blocked: false, - } - } - - if (httpResult.error || !httpResult.ok) { - const errMsg = - httpResult.error || - `HTTP ${httpResult.statusCode} from ${hook.url}` - logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, { - level: 'error', - }) - return { - command: hook.url, - succeeded: false, - output: errMsg, - blocked: false, - } - } - - // HTTP hooks must return JSON 鈥?parse and validate through Zod - const { json: httpJson, validationError: httpValidationError } = - parseHttpHookOutput(httpResult.body) - if (httpValidationError) { - throw new Error(httpValidationError) - } - if (httpJson && !isAsyncHookJSONOutput(httpJson)) { - logForDebugging( - `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`, - { level: 'verbose' }, - ) - } - const jsonBlocked = - httpJson && - !isAsyncHookJSONOutput(httpJson) && - isSyncHookJSONOutput(httpJson) && - httpJson.decision === 'block' - - // WorktreeCreate's consumer reads `output` as the bare filesystem - // path. Command hooks provide it via stdout; http hooks provide it - // via hookSpecificOutput.worktreePath. Without worktreePath, emit '' - // so the consumer's length filter skips it instead of treating the - // raw '{}' body as a path. - const output = - hookEvent === 'WorktreeCreate' - ? httpJson && - isSyncHookJSONOutput(httpJson) && - httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate' - ? httpJson.hookSpecificOutput.worktreePath - : '' - : httpResult.body - - return { - command: hook.url, - succeeded: true, - output, - blocked: !!jsonBlocked, - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error) - logForDebugging( - `${hookName} [${hook.url}] failed to run: ${errorMessage}`, - { level: 'error' }, - ) - return { - command: hook.url, - succeeded: false, - output: errorMessage, - blocked: false, - } - } - } - - // Handle command hooks - const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs - const { signal: abortSignal, cleanup } = createCombinedAbortSignal( - signal, - { timeoutMs: commandTimeoutMs }, - ) - try { - const result = await execCommandHook( - hook, - hookEvent, - hookName, - jsonInput, - abortSignal, - randomUUID(), - hookIndex, - pluginRoot, - pluginId, - ) - - // Clear timeout if hook completes - cleanup?.() - - if (result.aborted) { - logForDebugging(`${hookName} [${hook.command}] cancelled`) - return { - command: hook.command, - succeeded: false, - output: 'Hook cancelled', - blocked: false, - } - } - - logForDebugging( - `${hookName} [${hook.command}] completed with status ${result.status}`, - ) - - // Parse JSON for any messages to print out. - const { json, validationError } = parseHookOutput(result.stdout) - if (validationError) { - // Validation error is logged via logForDebugging and returned in output - throw new Error(validationError) - } - if (json && !isAsyncHookJSONOutput(json)) { - logForDebugging( - `Parsed JSON output from hook: ${jsonStringify(json)}`, - { level: 'verbose' }, - ) - } - - // Blocked if exit code 2 or JSON decision: 'block' - const jsonBlocked = - json && - !isAsyncHookJSONOutput(json) && - isSyncHookJSONOutput(json) && - json.decision === 'block' - const blocked = result.status === 2 || !!jsonBlocked - - // For successful hooks (exit code 0), use stdout; for failed hooks, use stderr - const output = - result.status === 0 ? result.stdout || '' : result.stderr || '' - - const watchPaths = - json && - isSyncHookJSONOutput(json) && - json.hookSpecificOutput && - 'watchPaths' in json.hookSpecificOutput - ? json.hookSpecificOutput.watchPaths - : undefined - - const systemMessage = - json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined - - return { - command: hook.command, - succeeded: result.status === 0, - output, - blocked, - watchPaths, - systemMessage, - } - } catch (error) { - // Clean up on error - cleanup?.() - - const errorMessage = - error instanceof Error ? error.message : String(error) - logForDebugging( - `${hookName} [${hook.command}] failed to run: ${errorMessage}`, - { level: 'error' }, - ) - return { - command: hook.command, - succeeded: false, - output: errorMessage, - blocked: false, - } - } - }, - ) - - // Wait for all hooks to complete and collect results - return await Promise.all(hookPromises) -} - -/** - * Execute pre-tool hooks if configured - * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') - * @param toolUseID The ID of the tool use - * @param toolInput The input that will be passed to the tool - * @param permissionMode Optional permission mode from toolPermissionContext - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @param toolUseContext Optional ToolUseContext for prompt-based hooks - * @returns Async generator that yields progress messages and returns blocking errors - */ -export async function* executePreToolHooks( - toolName: string, - toolUseID: string, - toolInput: ToolInput, - toolUseContext: ToolUseContext, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - requestPrompt?: ( - sourceName: string, - toolInputSummary?: string | null, - ) => (request: PromptRequest) => Promise, - toolInputSummary?: string | null, -): AsyncGenerator { - const appState = toolUseContext.getAppState() - const sessionId = toolUseContext.agentId ?? getSessionId() - if (!hasHookForEvent('PreToolUse', appState, sessionId)) { - return - } - - logForDebugging(`executePreToolHooks called for tool: ${toolName}`, { - level: 'verbose', - }) - - const hookInput: PreToolUseHookInput = { - ...createBaseHookInput(permissionMode, undefined, toolUseContext), - hook_event_name: 'PreToolUse', - tool_name: toolName, - tool_input: toolInput, - tool_use_id: toolUseID, - } - - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, - signal, - timeoutMs, - toolUseContext, - requestPrompt, - toolInputSummary, - }) -} - -/** - * Execute post-tool hooks if configured - * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') - * @param toolUseID The ID of the tool use - * @param toolInput The input that was passed to the tool - * @param toolResponse The response from the tool - * @param toolUseContext ToolUseContext for prompt-based hooks - * @param permissionMode Optional permission mode from toolPermissionContext - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and blocking errors for automated feedback - */ -export async function* executePostToolHooks( - toolName: string, - toolUseID: string, - toolInput: ToolInput, - toolResponse: ToolResponse, - toolUseContext: ToolUseContext, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): AsyncGenerator { - const hookInput: PostToolUseHookInput = { - ...createBaseHookInput(permissionMode, undefined, toolUseContext), - hook_event_name: 'PostToolUse', - tool_name: toolName, - tool_input: toolInput, - tool_response: toolResponse, - tool_use_id: toolUseID, - } - - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, - signal, - timeoutMs, - toolUseContext, - }) -} - -/** - * Execute post-tool-use-failure hooks if configured - * @param toolName The name of the tool (e.g., 'Write', 'Edit', 'Bash') - * @param toolUseID The ID of the tool use - * @param toolInput The input that was passed to the tool - * @param error The error message from the failed tool call - * @param toolUseContext ToolUseContext for prompt-based hooks - * @param isInterrupt Whether the tool was interrupted by user - * @param permissionMode Optional permission mode from toolPermissionContext - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and blocking errors - */ -export async function* executePostToolUseFailureHooks( - toolName: string, - toolUseID: string, - toolInput: ToolInput, - error: string, - toolUseContext: ToolUseContext, - isInterrupt?: boolean, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): AsyncGenerator { - const appState = toolUseContext.getAppState() - const sessionId = toolUseContext.agentId ?? getSessionId() - if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { - return - } - - const hookInput: PostToolUseFailureHookInput = { - ...createBaseHookInput(permissionMode, undefined, toolUseContext), - hook_event_name: 'PostToolUseFailure', - tool_name: toolName, - tool_input: toolInput, - tool_use_id: toolUseID, - error, - is_interrupt: isInterrupt, - } - - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, - signal, - timeoutMs, - toolUseContext, - }) -} - -export async function* executePermissionDeniedHooks( - toolName: string, - toolUseID: string, - toolInput: ToolInput, - reason: string, - toolUseContext: ToolUseContext, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): AsyncGenerator { - const appState = toolUseContext.getAppState() - const sessionId = toolUseContext.agentId ?? getSessionId() - if (!hasHookForEvent('PermissionDenied', appState, sessionId)) { - return - } - - const hookInput: PermissionDeniedHookInput = { - ...createBaseHookInput(permissionMode, undefined, toolUseContext), - hook_event_name: 'PermissionDenied', - tool_name: toolName, - tool_input: toolInput, - tool_use_id: toolUseID, - reason, - } - - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, - signal, - timeoutMs, - toolUseContext, - }) -} - -/** - * Execute notification hooks if configured - * @param notificationData The notification data to pass to hooks - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Promise that resolves when all hooks complete - */ -export async function executeNotificationHooks( - notificationData: { - message: string - title?: string - notificationType: string - }, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise { - const { message, title, notificationType } = notificationData - const hookInput: NotificationHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'Notification', - message, - title, - notification_type: notificationType, - } - - await executeHooksOutsideREPL({ - hookInput, - timeoutMs, - matchQuery: notificationType, - }) -} - -export async function executeStopFailureHooks( - lastMessage: AssistantMessage, - toolUseContext?: ToolUseContext, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise { - const appState = toolUseContext?.getAppState() - // executeHooksOutsideREPL hardcodes main sessionId (:2738). Agent frontmatter - // hooks (registerFrontmatterHooks) key by agentId; gating with agentId here - // would pass the gate but fail execution. Align gate with execution. - const sessionId = getSessionId() - if (!hasHookForEvent('StopFailure', appState, sessionId)) return - - const lastAssistantText = - extractTextContent(lastMessage.message.content, '\n').trim() || undefined - - // Some createAssistantAPIErrorMessage call sites omit `error` (e.g. - // image-size at errors.ts:431). Default to 'unknown' so matcher filtering - // at getMatchingHooks:1525 always applies. - const error = lastMessage.error ?? 'unknown' - const hookInput: StopFailureHookInput = { - ...createBaseHookInput(undefined, undefined, toolUseContext), - hook_event_name: 'StopFailure', - error, - error_details: lastMessage.errorDetails, - last_assistant_message: lastAssistantText, - } - - await executeHooksOutsideREPL({ - getAppState: toolUseContext?.getAppState, - hookInput, - timeoutMs, - matchQuery: error, - }) -} - -/** - * Execute stop hooks if configured - * @param toolUseContext ToolUseContext for prompt-based hooks - * @param permissionMode permission mode from toolPermissionContext - * @param signal AbortSignal to cancel hook execution - * @param stopHookActive Whether this call is happening within another stop hook - * @param isSubagent Whether the current execution context is a subagent - * @param messages Optional conversation history for prompt/function hooks - * @returns Async generator that yields progress messages and blocking errors - */ -export async function* executeStopHooks( - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - stopHookActive: boolean = false, - subagentId?: AgentId, - toolUseContext?: ToolUseContext, - messages?: Message[], - agentType?: string, - requestPrompt?: ( - sourceName: string, - toolInputSummary?: string | null, - ) => (request: PromptRequest) => Promise, -): AsyncGenerator { - const hookEvent = subagentId ? 'SubagentStop' : 'Stop' - const appState = toolUseContext?.getAppState() - const sessionId = toolUseContext?.agentId ?? getSessionId() - if (!hasHookForEvent(hookEvent, appState, sessionId)) { - return - } - - // Extract text content from the last assistant message so hooks can - // inspect the final response without reading the transcript file. - const lastAssistantMessage = messages - ? getLastAssistantMessage(messages) - : undefined - const lastAssistantText = lastAssistantMessage - ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() || - undefined - : undefined - - const hookInput: StopHookInput | SubagentStopHookInput = subagentId - ? { - ...createBaseHookInput(permissionMode), - hook_event_name: 'SubagentStop', - stop_hook_active: stopHookActive, - agent_id: subagentId, - agent_transcript_path: getAgentTranscriptPath(subagentId), - agent_type: agentType ?? '', - last_assistant_message: lastAssistantText, - } - : { - ...createBaseHookInput(permissionMode), - hook_event_name: 'Stop', - stop_hook_active: stopHookActive, - last_assistant_message: lastAssistantText, - } - - // Trust check is now centralized in executeHooks() - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - signal, - timeoutMs, - toolUseContext, - messages, - requestPrompt, - }) -} - -/** - * Execute TeammateIdle hooks when a teammate is about to go idle. - * If a hook blocks (exit code 2), the teammate should continue working instead of going idle. - * @param teammateName The name of the teammate going idle - * @param teamName The team this teammate belongs to - * @param permissionMode Optional permission mode - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and blocking errors - */ -export async function* executeTeammateIdleHooks( - teammateName: string, - teamName: string, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): AsyncGenerator { - const hookInput: TeammateIdleHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'TeammateIdle', - teammate_name: teammateName, - team_name: teamName, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - signal, - timeoutMs, - }) -} - -/** - * Execute TaskCreated hooks when a task is being created. - * If a hook blocks (exit code 2), the task creation should be prevented and feedback returned. - * @param taskId The ID of the task being created - * @param taskSubject The subject/title of the task - * @param taskDescription Optional description of the task - * @param teammateName Optional name of the teammate creating the task - * @param teamName Optional team name - * @param permissionMode Optional permission mode - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId - * @returns Async generator that yields progress messages and blocking errors - */ -export async function* executeTaskCreatedHooks( - taskId: string, - taskSubject: string, - taskDescription?: string, - teammateName?: string, - teamName?: string, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - toolUseContext?: ToolUseContext, -): AsyncGenerator { - const hookInput: TaskCreatedHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'TaskCreated', - task_id: taskId, - task_subject: taskSubject, - task_description: taskDescription, - teammate_name: teammateName, - team_name: teamName, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - signal, - timeoutMs, - toolUseContext, - }) -} - -/** - * Execute TaskCompleted hooks when a task is being marked as completed. - * If a hook blocks (exit code 2), the task completion should be prevented and feedback returned. - * @param taskId The ID of the task being completed - * @param taskSubject The subject/title of the task - * @param taskDescription Optional description of the task - * @param teammateName Optional name of the teammate completing the task - * @param teamName Optional team name - * @param permissionMode Optional permission mode - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @param toolUseContext Optional ToolUseContext for resolving appState and sessionId - * @returns Async generator that yields progress messages and blocking errors - */ -export async function* executeTaskCompletedHooks( - taskId: string, - taskSubject: string, - taskDescription?: string, - teammateName?: string, - teamName?: string, - permissionMode?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - toolUseContext?: ToolUseContext, -): AsyncGenerator { - const hookInput: TaskCompletedHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'TaskCompleted', - task_id: taskId, - task_subject: taskSubject, - task_description: taskDescription, - teammate_name: teammateName, - team_name: teamName, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - signal, - timeoutMs, - toolUseContext, - }) -} - -/** - * Execute start hooks if configured - * @param prompt The user prompt that will be passed to the tool - * @param permissionMode Permission mode from toolPermissionContext - * @param toolUseContext ToolUseContext for prompt-based hooks - * @returns Async generator that yields progress messages and hook results - */ -export async function* executeUserPromptSubmitHooks( - prompt: string, - permissionMode: string, - toolUseContext: ToolUseContext, - requestPrompt?: ( - sourceName: string, - toolInputSummary?: string | null, - ) => (request: PromptRequest) => Promise, -): AsyncGenerator { - const appState = toolUseContext.getAppState() - const sessionId = toolUseContext.agentId ?? getSessionId() - if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) { - return - } - - const hookInput: UserPromptSubmitHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'UserPromptSubmit', - prompt, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - signal: toolUseContext.abortController.signal, - timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, - toolUseContext, - requestPrompt, - }) -} - -/** - * Execute session start hooks if configured - * @param source The source of the session start (startup, resume, clear) - * @param sessionId Optional The session id to use as hook input - * @param agentType Optional The agent type (from --agent flag) running this session - * @param model Optional The model being used for this session - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and hook results - */ -export async function* executeSessionStartHooks( - source: 'startup' | 'resume' | 'clear' | 'compact', - sessionId?: string, - agentType?: string, - model?: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - forceSyncExecution?: boolean, -): AsyncGenerator { - const hookInput: SessionStartHookInput = { - ...createBaseHookInput(undefined, sessionId), - hook_event_name: 'SessionStart', - source, - agent_type: agentType, - model, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - matchQuery: source, - signal, - timeoutMs, - forceSyncExecution, - }) -} - -/** - * Execute setup hooks if configured - * @param trigger The trigger type ('init' or 'maintenance') - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @param forceSyncExecution If true, async hooks will not be backgrounded - * @returns Async generator that yields progress messages and hook results - */ -export async function* executeSetupHooks( - trigger: 'init' | 'maintenance', - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - forceSyncExecution?: boolean, -): AsyncGenerator { - const hookInput: SetupHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'Setup', - trigger, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - matchQuery: trigger, - signal, - timeoutMs, - forceSyncExecution, - }) -} - -/** - * Execute subagent start hooks if configured - * @param agentId The unique identifier for the subagent - * @param agentType The type/name of the subagent being started - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and hook results - */ -export async function* executeSubagentStartHooks( - agentId: string, - agentType: string, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): AsyncGenerator { - const hookInput: SubagentStartHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'SubagentStart', - agent_id: agentId, - agent_type: agentType, - } - - yield* executeHooks({ - hookInput, - toolUseID: randomUUID(), - matchQuery: agentType, - signal, - timeoutMs, - }) -} - -/** - * Execute pre-compact hooks if configured - * @param compactData The compact data to pass to hooks - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Object with optional newCustomInstructions and userDisplayMessage - */ -export async function executePreCompactHooks( - compactData: { - trigger: 'manual' | 'auto' - customInstructions: string | null - }, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise<{ - newCustomInstructions?: string - userDisplayMessage?: string -}> { - const hookInput: PreCompactHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'PreCompact', - trigger: compactData.trigger, - custom_instructions: compactData.customInstructions, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - matchQuery: compactData.trigger, - signal, - timeoutMs, - }) - - if (results.length === 0) { - return {} - } - - // Extract custom instructions from successful hooks with non-empty output - const successfulOutputs = results - .filter(result => result.succeeded && result.output.trim().length > 0) - .map(result => result.output.trim()) - - // Build user display messages with command info - const displayMessages: string[] = [] - for (const result of results) { - if (result.succeeded) { - if (result.output.trim()) { - displayMessages.push( - `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`, - ) - } else { - displayMessages.push( - `PreCompact [${result.command}] completed successfully`, - ) - } - } else { - if (result.output.trim()) { - displayMessages.push( - `PreCompact [${result.command}] failed: ${result.output.trim()}`, - ) - } else { - displayMessages.push(`PreCompact [${result.command}] failed`) - } - } - } - - return { - newCustomInstructions: - successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined, - userDisplayMessage: - displayMessages.length > 0 ? displayMessages.join('\n') : undefined, - } -} - -/** - * Execute post-compact hooks if configured - * @param compactData The compact data to pass to hooks, including the summary - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Object with optional userDisplayMessage - */ -export async function executePostCompactHooks( - compactData: { - trigger: 'manual' | 'auto' - compactSummary: string - }, - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise<{ - userDisplayMessage?: string -}> { - const hookInput: PostCompactHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'PostCompact', - trigger: compactData.trigger, - compact_summary: compactData.compactSummary, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - matchQuery: compactData.trigger, - signal, - timeoutMs, - }) - - if (results.length === 0) { - return {} - } - - const displayMessages: string[] = [] - for (const result of results) { - if (result.succeeded) { - if (result.output.trim()) { - displayMessages.push( - `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`, - ) - } else { - displayMessages.push( - `PostCompact [${result.command}] completed successfully`, - ) - } - } else { - if (result.output.trim()) { - displayMessages.push( - `PostCompact [${result.command}] failed: ${result.output.trim()}`, - ) - } else { - displayMessages.push(`PostCompact [${result.command}] failed`) - } - } - } - - return { - userDisplayMessage: - displayMessages.length > 0 ? displayMessages.join('\n') : undefined, - } -} - -/** - * Execute session end hooks if configured - * @param reason The reason for ending the session - * @param options Optional parameters including app state functions and signal - * @returns Promise that resolves when all hooks complete - */ -export async function executeSessionEndHooks( - reason: ExitReason, - options?: { - getAppState?: () => AppState - setAppState?: (updater: (prev: AppState) => AppState) => void - signal?: AbortSignal - timeoutMs?: number - }, -): Promise { - const { - getAppState, - setAppState, - signal, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - } = options || {} - - const hookInput: SessionEndHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'SessionEnd', - reason, - } - - const results = await executeHooksOutsideREPL({ - getAppState, - hookInput, - matchQuery: reason, - signal, - timeoutMs, - }) - - // During shutdown, Ink is unmounted so we can write directly to stderr - for (const result of results) { - if (!result.succeeded && result.output) { - process.stderr.write( - `SessionEnd hook [${result.command}] failed: ${result.output}\n`, - ) - } - } - - // Clear session hooks after execution - if (setAppState) { - const sessionId = getSessionId() - clearSessionHooks(setAppState, sessionId) - } -} - -/** - * Execute permission request hooks if configured - * These hooks are called when a permission dialog would be displayed to the user. - * Hooks can approve or deny the permission request programmatically. - * @param toolName The name of the tool requesting permission - * @param toolUseID The ID of the tool use - * @param toolInput The input that would be passed to the tool - * @param toolUseContext ToolUseContext for the request - * @param permissionMode Optional permission mode from toolPermissionContext - * @param permissionSuggestions Optional permission suggestions (the "always allow" options) - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Async generator that yields progress messages and returns aggregated result - */ -export async function* executePermissionRequestHooks( - toolName: string, - toolUseID: string, - toolInput: ToolInput, - toolUseContext: ToolUseContext, - permissionMode?: string, - permissionSuggestions?: PermissionUpdate[], - signal?: AbortSignal, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - requestPrompt?: ( - sourceName: string, - toolInputSummary?: string | null, - ) => (request: PromptRequest) => Promise, - toolInputSummary?: string | null, -): AsyncGenerator { - logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`) - - const hookInput: PermissionRequestHookInput = { - ...createBaseHookInput(permissionMode, undefined, toolUseContext), - hook_event_name: 'PermissionRequest', - tool_name: toolName, - tool_input: toolInput, - permission_suggestions: permissionSuggestions, - } - - yield* executeHooks({ - hookInput, - toolUseID, - matchQuery: toolName, - signal, - timeoutMs, - toolUseContext, - requestPrompt, - toolInputSummary, - }) -} - -export type ConfigChangeSource = - | 'user_settings' - | 'project_settings' - | 'local_settings' - | 'policy_settings' - | 'skills' - -/** - * Execute config change hooks when configuration files change during a session. - * Fired by file watchers when settings, skills, or commands change on disk. - * Enables enterprise admins to audit/log configuration changes for security. - * - * Policy settings are enterprise-managed and must never be blockable by hooks. - * Hooks still fire (for audit logging) but blocking results are ignored 鈥?callers - * will always see an empty result for policy sources. - * - * @param source The type of config that changed - * @param filePath Optional path to the changed file - * @param timeoutMs Optional timeout in milliseconds for hook execution - */ -export async function executeConfigChangeHooks( - source: ConfigChangeSource, - filePath?: string, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise { - const hookInput: ConfigChangeHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'ConfigChange', - source, - file_path: filePath, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - timeoutMs, - matchQuery: source, - }) - - // Policy settings are enterprise-managed 鈥?hooks fire for audit logging - // but must never block policy changes from being applied - if (source === 'policy_settings') { - return results.map(r => ({ ...r, blocked: false })) - } - - return results -} - -async function executeEnvHooks( - hookInput: HookInput, - timeoutMs: number, -): Promise<{ - results: HookOutsideReplResult[] - watchPaths: string[] - systemMessages: string[] -}> { - const results = await executeHooksOutsideREPL({ hookInput, timeoutMs }) - if (results.length > 0) { - invalidateSessionEnvCache() - } - const watchPaths = results.flatMap(r => r.watchPaths ?? []) - const systemMessages = results - .map(r => r.systemMessage) - .filter((m): m is string => !!m) - return { results, watchPaths, systemMessages } -} - -export function executeCwdChangedHooks( - oldCwd: string, - newCwd: string, - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise<{ - results: HookOutsideReplResult[] - watchPaths: string[] - systemMessages: string[] -}> { - const hookInput: CwdChangedHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'CwdChanged', - old_cwd: oldCwd, - new_cwd: newCwd, - } - return executeEnvHooks(hookInput, timeoutMs) -} - -export function executeFileChangedHooks( - filePath: string, - event: 'change' | 'add' | 'unlink', - timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, -): Promise<{ - results: HookOutsideReplResult[] - watchPaths: string[] - systemMessages: string[] -}> { - const hookInput: FileChangedHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'FileChanged', - file_path: filePath, - event, - } - return executeEnvHooks(hookInput, timeoutMs) -} - -export type InstructionsLoadReason = - | 'session_start' - | 'nested_traversal' - | 'path_glob_match' - | 'include' - | 'compact' - -export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed' - -/** - * Check if InstructionsLoaded hooks are configured (without executing them). - * Callers should check this before invoking executeInstructionsLoadedHooks to avoid - * building hook inputs for every instruction file when no hook is configured. - * - * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered - * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). Session- - * derived hooks (structured output enforcement etc.) are internal and not checked. - */ -export function hasInstructionsLoadedHook(): boolean { - const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded'] - if (snapshotHooks && snapshotHooks.length > 0) return true - const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded'] - if (registeredHooks && registeredHooks.length > 0) return true - return false -} - -/** - * Execute InstructionsLoaded hooks when an instruction file (CLAUDE.md or - * .claude/rules/*.md) is loaded into context. Fire-and-forget 鈥?this hook is - * for observability/audit only and does not support blocking. - * - * Dispatch sites: - * - Eager load at session start (getMemoryFiles in claudemd.ts) - * - Eager reload after compaction (getMemoryFiles cache cleared by - * runPostCompactCleanup; next call reports load_reason: 'compact') - * - Lazy load when Claude touches a file that triggers nested CLAUDE.md or - * conditional rules with paths: frontmatter (memoryFilesToAttachments in - * attachments.ts) - */ -export async function executeInstructionsLoadedHooks( - filePath: string, - memoryType: InstructionsMemoryType, - loadReason: InstructionsLoadReason, - options?: { - globs?: string[] - triggerFilePath?: string - parentFilePath?: string - timeoutMs?: number - }, -): Promise { - const { - globs, - triggerFilePath, - parentFilePath, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - } = options ?? {} - - const hookInput: InstructionsLoadedHookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'InstructionsLoaded', - file_path: filePath, - memory_type: memoryType, - load_reason: loadReason, - globs, - trigger_file_path: triggerFilePath, - parent_file_path: parentFilePath, - } - - await executeHooksOutsideREPL({ - hookInput, - timeoutMs, - matchQuery: loadReason, - }) -} - -/** Result of an elicitation hook execution (non-REPL path). */ -export type ElicitationHookResult = { - elicitationResponse?: ElicitationResponse - blockingError?: HookBlockingError -} - -/** Result of an elicitation-result hook execution (non-REPL path). */ -export type ElicitationResultHookResult = { - elicitationResultResponse?: ElicitationResponse - blockingError?: HookBlockingError -} - -/** - * Parse elicitation-specific fields from a HookOutsideReplResult. - * Mirrors the relevant branches of processHookJSONOutput for Elicitation - * and ElicitationResult hook events. - */ -function parseElicitationHookOutput( - result: HookOutsideReplResult, - expectedEventName: 'Elicitation' | 'ElicitationResult', -): { - response?: ElicitationResponse - blockingError?: HookBlockingError -} { - // Exit code 2 = blocking (same as executeHooks path) - if (result.blocked && !result.succeeded) { - return { - blockingError: { - blockingError: result.output || `Elicitation blocked by hook`, - command: result.command, - }, - } - } - - if (!result.output.trim()) { - return {} - } - - // Try to parse JSON output for structured elicitation response - const trimmed = result.output.trim() - if (!trimmed.startsWith('{')) { - return {} - } - - try { - const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed)) - if (isAsyncHookJSONOutput(parsed)) { - return {} - } - if (!isSyncHookJSONOutput(parsed)) { - return {} - } - - // Check for top-level decision: 'block' (exit code 0 + JSON block) - if (parsed.decision === 'block' || result.blocked) { - return { - blockingError: { - blockingError: parsed.reason || 'Elicitation blocked by hook', - command: result.command, - }, - } - } - - const specific = parsed.hookSpecificOutput - if (!specific || specific.hookEventName !== expectedEventName) { - return {} - } - - if (!specific.action) { - return {} - } - - const response: ElicitationResponse = { - action: specific.action, - content: specific.content as ElicitationResponse['content'] | undefined, - } - - const out: { - response?: ElicitationResponse - blockingError?: HookBlockingError - } = { response } - - if (specific.action === 'decline') { - out.blockingError = { - blockingError: - parsed.reason || - (expectedEventName === 'Elicitation' - ? 'Elicitation denied by hook' - : 'Elicitation result blocked by hook'), - command: result.command, - } - } - - return out - } catch { - return {} - } -} - -export async function executeElicitationHooks({ - serverName, - message, - requestedSchema, - permissionMode, - signal, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - mode, - url, - elicitationId, -}: { - serverName: string - message: string - requestedSchema?: Record - permissionMode?: string - signal?: AbortSignal - timeoutMs?: number - mode?: 'form' | 'url' - url?: string - elicitationId?: string -}): Promise { - const hookInput: ElicitationHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'Elicitation', - mcp_server_name: serverName, - message, - mode, - url, - elicitation_id: elicitationId, - requested_schema: requestedSchema, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - matchQuery: serverName, - signal, - timeoutMs, - }) - - let elicitationResponse: ElicitationResponse | undefined - let blockingError: HookBlockingError | undefined - - for (const result of results) { - const parsed = parseElicitationHookOutput(result, 'Elicitation') - if (parsed.blockingError) { - blockingError = parsed.blockingError - } - if (parsed.response) { - elicitationResponse = parsed.response - } - } - - return { elicitationResponse, blockingError } -} - -export async function executeElicitationResultHooks({ - serverName, - action, - content, - permissionMode, - signal, - timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, - mode, - elicitationId, -}: { - serverName: string - action: 'accept' | 'decline' | 'cancel' - content?: Record - permissionMode?: string - signal?: AbortSignal - timeoutMs?: number - mode?: 'form' | 'url' - elicitationId?: string -}): Promise { - const hookInput: ElicitationResultHookInput = { - ...createBaseHookInput(permissionMode), - hook_event_name: 'ElicitationResult', - mcp_server_name: serverName, - elicitation_id: elicitationId, - mode, - action, - content, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - matchQuery: serverName, - signal, - timeoutMs, - }) - - let elicitationResultResponse: ElicitationResponse | undefined - let blockingError: HookBlockingError | undefined - - for (const result of results) { - const parsed = parseElicitationHookOutput(result, 'ElicitationResult') - if (parsed.blockingError) { - blockingError = parsed.blockingError - } - if (parsed.response) { - elicitationResultResponse = parsed.response - } - } - - return { elicitationResultResponse, blockingError } -} - -/** - * Execute status line command if configured - * @param statusLineInput The structured status input that will be converted to JSON - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns The status line text to display, or undefined if no command configured - */ -export async function executeStatusLineCommand( - statusLineInput: StatusLineCommandInput, - signal?: AbortSignal, - timeoutMs: number = 5000, // Short timeout for status line - logResult: boolean = false, -): Promise { - // Check if all hooks (including statusLine) are disabled by managed settings - if (shouldDisableAllHooksIncludingManaged()) { - return undefined - } - - // SECURITY: ALL hooks require workspace trust in interactive mode - // This centralized check prevents RCE vulnerabilities for all current and future hooks - if (shouldSkipHookDueToTrust()) { - logForDebugging( - `Skipping StatusLine command execution - workspace trust not accepted`, - ) - return undefined - } - - // When disableAllHooks is set in non-managed settings, only managed statusLine runs - // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) - let statusLine - if (shouldAllowManagedHooksOnly()) { - statusLine = getSettingsForSource('policySettings')?.statusLine - } else { - statusLine = getSettings_DEPRECATED()?.statusLine - } - - if (!statusLine || statusLine.type !== 'command') { - return undefined - } - - // Use provided signal or create a default one - const abortSignal = signal || AbortSignal.timeout(timeoutMs) - - try { - // Convert status input to JSON - const jsonInput = jsonStringify(statusLineInput) - - const result = await execCommandHook( - statusLine, - 'StatusLine', - 'statusLine', - jsonInput, - abortSignal, - randomUUID(), - ) - - if (result.aborted) { - return undefined - } - - // For successful hooks (exit code 0), use stdout - if (result.status === 0) { - // Trim and split output into lines, then join with newlines - const output = result.stdout - .trim() - .split('\n') - .flatMap(line => line.trim() || []) - .join('\n') - - if (output) { - if (logResult) { - logForDebugging( - `StatusLine [${statusLine.command}] completed with status ${result.status}`, - ) - } - return output - } - } else if (logResult) { - logForDebugging( - `StatusLine [${statusLine.command}] completed with status ${result.status}`, - { level: 'warn' }, - ) - } - - return undefined - } catch (error) { - logForDebugging(`Status hook failed: ${error}`, { level: 'error' }) - return undefined - } -} - -/** - * Execute file suggestion command if configured - * @param fileSuggestionInput The structured input that will be converted to JSON - * @param signal Optional AbortSignal to cancel hook execution - * @param timeoutMs Optional timeout in milliseconds for hook execution - * @returns Array of file paths, or empty array if no command configured - */ -export async function executeFileSuggestionCommand( - fileSuggestionInput: FileSuggestionCommandInput, - signal?: AbortSignal, - timeoutMs: number = 5000, // Short timeout for typeahead suggestions -): Promise { - // Check if all hooks are disabled by managed settings - if (shouldDisableAllHooksIncludingManaged()) { - return [] - } - - // SECURITY: ALL hooks require workspace trust in interactive mode - // This centralized check prevents RCE vulnerabilities for all current and future hooks - if (shouldSkipHookDueToTrust()) { - logForDebugging( - `Skipping FileSuggestion command execution - workspace trust not accepted`, - ) - return [] - } - - // When disableAllHooks is set in non-managed settings, only managed fileSuggestion runs - // (non-managed settings cannot disable managed commands, but non-managed commands are disabled) - let fileSuggestion - if (shouldAllowManagedHooksOnly()) { - fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion - } else { - fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion - } - - if (!fileSuggestion || fileSuggestion.type !== 'command') { - return [] - } - - // Use provided signal or create a default one - const abortSignal = signal || AbortSignal.timeout(timeoutMs) - - try { - const jsonInput = jsonStringify(fileSuggestionInput) - - const hook = { type: 'command' as const, command: fileSuggestion.command } - - const result = await execCommandHook( - hook, - 'FileSuggestion', - 'FileSuggestion', - jsonInput, - abortSignal, - randomUUID(), - ) - - if (result.aborted || result.status !== 0) { - return [] - } - - return result.stdout - .split('\n') - .map(line => line.trim()) - .filter(Boolean) - } catch (error) { - logForDebugging(`File suggestion helper failed: ${error}`, { - level: 'error', - }) - return [] - } -} - -async function executeFunctionHook({ - hook, - messages, - hookName, - toolUseID, - hookEvent, - timeoutMs, - signal, -}: { - hook: FunctionHook - messages: Message[] - hookName: string - toolUseID: string - hookEvent: HookEvent - timeoutMs: number - signal?: AbortSignal -}): Promise { - const callbackTimeoutMs = hook.timeout ?? timeoutMs - const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { - timeoutMs: callbackTimeoutMs, - }) - - try { - // Check if already aborted - if (abortSignal.aborted) { - cleanup() - return { - outcome: 'cancelled', - hook, - } - } - - // Execute callback with abort signal - const passed = await new Promise((resolve, reject) => { - // Handle abort signal - const onAbort = () => reject(new Error('Function hook cancelled')) - abortSignal.addEventListener('abort', onAbort) - - // Execute callback - Promise.resolve(hook.callback(messages, abortSignal)) - .then(result => { - abortSignal.removeEventListener('abort', onAbort) - resolve(result) - }) - .catch(error => { - abortSignal.removeEventListener('abort', onAbort) - reject(error) - }) - }) - - cleanup() - - if (passed) { - return { - outcome: 'success', - hook, - } - } - return { - blockingError: { - blockingError: hook.errorMessage, - command: 'function', - }, - outcome: 'blocking', - hook, - } - } catch (error) { - cleanup() - - // Handle cancellation - if ( - error instanceof Error && - (error.message === 'Function hook cancelled' || - error.name === 'AbortError') - ) { - return { - outcome: 'cancelled', - hook, - } - } - - // Log for monitoring - logError(error) - return { - message: createAttachmentMessage({ - type: 'hook_error_during_execution', - hookName, - toolUseID, - hookEvent, - content: - error instanceof Error - ? error.message - : 'Function hook execution error', - }), - outcome: 'non_blocking_error', - hook, - } - } -} - -async function executeHookCallback({ - toolUseID, - hook, - hookEvent, - hookInput, - signal, - hookIndex, - toolUseContext, -}: { - toolUseID: string - hook: HookCallback - hookEvent: HookEvent - hookInput: HookInput - signal: AbortSignal - hookIndex?: number - toolUseContext?: ToolUseContext -}): Promise { - // Create context for callbacks that need state access - const context = toolUseContext - ? { - getAppState: toolUseContext.getAppState, - updateAttributionState: toolUseContext.updateAttributionState, - } - : undefined - const json = await hook.callback( - hookInput, - toolUseID, - signal, - hookIndex, - context, - ) - if (isAsyncHookJSONOutput(json)) { - return { - outcome: 'success', - hook, - } - } - - const processed = processHookJSONOutput({ - json, - command: 'callback', - // TODO: If the hook came from a plugin, use the full path to the plugin for easier debugging - hookName: `${hookEvent}:Callback`, - toolUseID, - hookEvent, - expectedHookEvent: hookEvent, - // Callbacks don't have stdout/stderr/exitCode - stdout: undefined, - stderr: undefined, - exitCode: undefined, - }) - return { - ...processed, - outcome: 'success', - hook, - } -} - -/** - * Check if WorktreeCreate hooks are configured (without executing them). - * - * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered - * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). - * - * Must mirror the managedOnly filtering in getHooksConfig() 鈥?when - * shouldAllowManagedHooksOnly() is true, plugin hooks (pluginRoot set) are - * skipped at execution, so we must also skip them here. Otherwise this returns - * true but executeWorktreeCreateHook() finds no matching hooks and throws, - * blocking the git-worktree fallback. - */ -export function hasWorktreeCreateHook(): boolean { - const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate'] - if (snapshotHooks && snapshotHooks.length > 0) return true - const registeredHooks = getRegisteredHooks()?.['WorktreeCreate'] - if (!registeredHooks || registeredHooks.length === 0) return false - // Mirror getHooksConfig(): skip plugin hooks in managed-only mode - const managedOnly = shouldAllowManagedHooksOnly() - return registeredHooks.some( - matcher => !(managedOnly && 'pluginRoot' in matcher), - ) -} - -/** - * Execute WorktreeCreate hooks. - * Returns the worktree path from hook stdout. - * Throws if hooks fail or produce no output. - * Callers should check hasWorktreeCreateHook() before calling this. - */ -export async function executeWorktreeCreateHook( - name: string, -): Promise<{ worktreePath: string }> { - const hookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'WorktreeCreate' as const, - name, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, - }) - - // Find the first successful result with non-empty output - const successfulResult = results.find( - r => r.succeeded && r.output.trim().length > 0, - ) - - if (!successfulResult) { - const failedOutputs = results - .filter(r => !r.succeeded) - .map(r => `${r.command}: ${r.output.trim() || 'no output'}`) - throw new Error( - `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`, - ) - } - - const worktreePath = successfulResult.output.trim() - return { worktreePath } -} - -/** - * Execute WorktreeRemove hooks if configured. - * Returns true if hooks were configured and ran, false if no hooks are configured. - * - * Checks both settings-file hooks (getHooksConfigFromSnapshot) and registered - * hooks (plugin hooks + SDK callback hooks via registerHookCallbacks). - */ -export async function executeWorktreeRemoveHook( - worktreePath: string, -): Promise { - const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove'] - const registeredHooks = getRegisteredHooks()?.['WorktreeRemove'] - const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0 - const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0 - if (!hasSnapshotHooks && !hasRegisteredHooks) { - return false - } - - const hookInput = { - ...createBaseHookInput(undefined), - hook_event_name: 'WorktreeRemove' as const, - worktree_path: worktreePath, - } - - const results = await executeHooksOutsideREPL({ - hookInput, - timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, - }) - - if (results.length === 0) { - return false - } - - for (const result of results) { - if (!result.succeeded) { - logForDebugging( - `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`, - { level: 'error' }, - ) - } - } - - return true -} - -function getHookDefinitionsForTelemetry( - matchedHooks: MatchedHook[], -): Array<{ type: string; command?: string; prompt?: string; name?: string }> { - return matchedHooks.map(({ hook }) => { - if (hook.type === 'command') { - return { type: 'command', command: hook.command } - } else if (hook.type === 'prompt') { - return { type: 'prompt', prompt: hook.prompt } - } else if (hook.type === 'http') { - return { type: 'http', command: hook.url } - } else if (hook.type === 'function') { - return { type: 'function', name: 'function' } - } else if (hook.type === 'callback') { - return { type: 'callback', name: 'callback' } - } - return { type: 'unknown' } - }) -} diff --git a/hook-dump/src_utils_hooks_AsyncHookRegistry.ts b/hook-dump/src_utils_hooks_AsyncHookRegistry.ts deleted file mode 100644 index 6d063d0e45..0000000000 --- a/hook-dump/src_utils_hooks_AsyncHookRegistry.ts +++ /dev/null @@ -1,312 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\AsyncHookRegistry.ts -LINES: 309 -========== -import type { - AsyncHookJSONOutput, - HookEvent, - SyncHookJSONOutput, -} from 'src/entrypoints/agentSdkTypes.js' -import { logForDebugging } from '../debug.js' -import type { ShellCommand } from '../ShellCommand.js' -import { invalidateSessionEnvCache } from '../sessionEnvironment.js' -import { jsonParse, jsonStringify } from '../slowOperations.js' -import { emitHookResponse, startHookProgressInterval } from './hookEvents.js' - -export type PendingAsyncHook = { - processId: string - hookId: string - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - startTime: number - timeout: number - command: string - responseAttachmentSent: boolean - shellCommand?: ShellCommand - stopProgressInterval: () => void -} - -// Global registry state -const pendingHooks = new Map() - -export function registerPendingAsyncHook({ - processId, - hookId, - asyncResponse, - hookName, - hookEvent, - command, - shellCommand, - toolName, - pluginId, -}: { - processId: string - hookId: string - asyncResponse: AsyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - command: string - shellCommand: ShellCommand - toolName?: string - pluginId?: string -}): void { - const timeout = asyncResponse.asyncTimeout || 15000 // Default 15s - logForDebugging( - `Hooks: Registering async hook ${processId} (${hookName}) with timeout ${timeout}ms`, - ) - const stopProgressInterval = startHookProgressInterval({ - hookId, - hookName, - hookEvent, - getOutput: async () => { - const taskOutput = pendingHooks.get(processId)?.shellCommand?.taskOutput - if (!taskOutput) { - return { stdout: '', stderr: '', output: '' } - } - const stdout = await taskOutput.getStdout() - const stderr = taskOutput.getStderr() - return { stdout, stderr, output: stdout + stderr } - }, - }) - pendingHooks.set(processId, { - processId, - hookId, - hookName, - hookEvent, - toolName, - pluginId, - command, - startTime: Date.now(), - timeout, - responseAttachmentSent: false, - shellCommand, - stopProgressInterval, - }) -} - -export function getPendingAsyncHooks(): PendingAsyncHook[] { - return Array.from(pendingHooks.values()).filter( - hook => !hook.responseAttachmentSent, - ) -} - -async function finalizeHook( - hook: PendingAsyncHook, - exitCode: number, - outcome: 'success' | 'error' | 'cancelled', -): Promise { - hook.stopProgressInterval() - const taskOutput = hook.shellCommand?.taskOutput - const stdout = taskOutput ? await taskOutput.getStdout() : '' - const stderr = taskOutput?.getStderr() ?? '' - hook.shellCommand?.cleanup() - emitHookResponse({ - hookId: hook.hookId, - hookName: hook.hookName, - hookEvent: hook.hookEvent, - output: stdout + stderr, - stdout, - stderr, - exitCode, - outcome, - }) -} - -export async function checkForAsyncHookResponses(): Promise< - Array<{ - processId: string - response: SyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - stdout: string - stderr: string - exitCode?: number - }> -> { - const responses: { - processId: string - response: SyncHookJSONOutput - hookName: string - hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' - toolName?: string - pluginId?: string - stdout: string - stderr: string - exitCode?: number - }[] = [] - - const pendingCount = pendingHooks.size - logForDebugging(`Hooks: Found ${pendingCount} total hooks in registry`) - - // Snapshot hooks before processing 鈥?we'll mutate the map after. - const hooks = Array.from(pendingHooks.values()) - - const settled = await Promise.allSettled( - hooks.map(async hook => { - const stdout = (await hook.shellCommand?.taskOutput.getStdout()) ?? '' - const stderr = hook.shellCommand?.taskOutput.getStderr() ?? '' - logForDebugging( - `Hooks: Checking hook ${hook.processId} (${hook.hookName}) - attachmentSent: ${hook.responseAttachmentSent}, stdout length: ${stdout.length}`, - ) - - if (!hook.shellCommand) { - logForDebugging( - `Hooks: Hook ${hook.processId} has no shell command, removing from registry`, - ) - hook.stopProgressInterval() - return { type: 'remove' as const, processId: hook.processId } - } - - logForDebugging(`Hooks: Hook shell status ${hook.shellCommand.status}`) - - if (hook.shellCommand.status === 'killed') { - logForDebugging( - `Hooks: Hook ${hook.processId} is ${hook.shellCommand.status}, removing from registry`, - ) - hook.stopProgressInterval() - hook.shellCommand.cleanup() - return { type: 'remove' as const, processId: hook.processId } - } - - if (hook.shellCommand.status !== 'completed') { - return { type: 'skip' as const } - } - - if (hook.responseAttachmentSent || !stdout.trim()) { - logForDebugging( - `Hooks: Skipping hook ${hook.processId} - already delivered/sent or no stdout`, - ) - hook.stopProgressInterval() - return { type: 'remove' as const, processId: hook.processId } - } - - const lines = stdout.split('\n') - logForDebugging( - `Hooks: Processing ${lines.length} lines of stdout for ${hook.processId}`, - ) - - const execResult = await hook.shellCommand.result - const exitCode = execResult.code - - let response: SyncHookJSONOutput = {} - for (const line of lines) { - if (line.trim().startsWith('{')) { - logForDebugging( - `Hooks: Found JSON line: ${line.trim().substring(0, 100)}...`, - ) - try { - const parsed = jsonParse(line.trim()) - if (!('async' in parsed)) { - logForDebugging( - `Hooks: Found sync response from ${hook.processId}: ${jsonStringify(parsed)}`, - ) - response = parsed - break - } - } catch { - logForDebugging( - `Hooks: Failed to parse JSON from ${hook.processId}: ${line.trim()}`, - ) - } - } - } - - hook.responseAttachmentSent = true - await finalizeHook(hook, exitCode, exitCode === 0 ? 'success' : 'error') - - return { - type: 'response' as const, - processId: hook.processId, - isSessionStart: hook.hookEvent === 'SessionStart', - payload: { - processId: hook.processId, - response, - hookName: hook.hookName, - hookEvent: hook.hookEvent, - toolName: hook.toolName, - pluginId: hook.pluginId, - stdout, - stderr, - exitCode, - }, - } - }), - ) - - // allSettled 鈥?isolate failures so one throwing callback doesn't orphan - // already-applied side effects (responseAttachmentSent, finalizeHook) from others. - let sessionStartCompleted = false - for (const s of settled) { - if (s.status !== 'fulfilled') { - logForDebugging( - `Hooks: checkForAsyncHookResponses callback rejected: ${s.reason}`, - { level: 'error' }, - ) - continue - } - const r = s.value - if (r.type === 'remove') { - pendingHooks.delete(r.processId) - } else if (r.type === 'response') { - responses.push(r.payload) - pendingHooks.delete(r.processId) - if (r.isSessionStart) sessionStartCompleted = true - } - } - - if (sessionStartCompleted) { - logForDebugging( - `Invalidating session env cache after SessionStart hook completed`, - ) - invalidateSessionEnvCache() - } - - logForDebugging( - `Hooks: checkForNewResponses returning ${responses.length} responses`, - ) - return responses -} - -export function removeDeliveredAsyncHooks(processIds: string[]): void { - for (const processId of processIds) { - const hook = pendingHooks.get(processId) - if (hook && hook.responseAttachmentSent) { - logForDebugging(`Hooks: Removing delivered hook ${processId}`) - hook.stopProgressInterval() - pendingHooks.delete(processId) - } - } -} - -export async function finalizePendingAsyncHooks(): Promise { - const hooks = Array.from(pendingHooks.values()) - await Promise.all( - hooks.map(async hook => { - if (hook.shellCommand?.status === 'completed') { - const result = await hook.shellCommand.result - await finalizeHook( - hook, - result.code, - result.code === 0 ? 'success' : 'error', - ) - } else { - if (hook.shellCommand && hook.shellCommand.status !== 'killed') { - hook.shellCommand.kill() - } - await finalizeHook(hook, 1, 'cancelled') - } - }), - ) - pendingHooks.clear() -} - -// Test utility function to clear all hooks -export function clearAllAsyncHooks(): void { - for (const hook of pendingHooks.values()) { - hook.stopProgressInterval() - } - pendingHooks.clear() -} diff --git a/hook-dump/src_utils_hooks_apiQueryHookHelper.ts b/hook-dump/src_utils_hooks_apiQueryHookHelper.ts deleted file mode 100644 index 8269cf3c5b..0000000000 --- a/hook-dump/src_utils_hooks_apiQueryHookHelper.ts +++ /dev/null @@ -1,144 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\apiQueryHookHelper.ts -LINES: 141 -========== -import { randomUUID } from 'crypto' -import type { QuerySource } from '../../constants/querySource.js' -import { queryModelWithoutStreaming } from '../../services/api/claude.js' -import type { Message } from '../../types/message.js' -import { createAbortController } from '../../utils/abortController.js' -import { logError } from '../../utils/log.js' -import { toError } from '../errors.js' -import { extractTextContent } from '../messages.js' -import { asSystemPrompt } from '../systemPromptType.js' -import type { REPLHookContext } from './postSamplingHooks.js' - -export type ApiQueryHookContext = REPLHookContext & { - queryMessageCount?: number -} - -export type ApiQueryHookConfig = { - name: QuerySource - shouldRun: (context: ApiQueryHookContext) => Promise - - // Build the complete message list to send to the API - buildMessages: (context: ApiQueryHookContext) => Message[] - - // Optional: override system prompt (defaults to context.systemPrompt) - systemPrompt?: string - - // Optional: whether to use tools from context (defaults to true) - // Set to false to pass empty tools array - useTools?: boolean - - parseResponse: (content: string, context: ApiQueryHookContext) => TResult - logResult: ( - result: ApiQueryResult, - context: ApiQueryHookContext, - ) => void - // Must be a function to ensure lazy loading (config is accessed before allowed) - // Receives context so callers can inherit the main loop model if desired. - getModel: (context: ApiQueryHookContext) => string -} - -export type ApiQueryResult = - | { - type: 'success' - queryName: string - result: TResult - messageId: string - model: string - uuid: string - } - | { - type: 'error' - queryName: string - error: Error - uuid: string - } - -export function createApiQueryHook( - config: ApiQueryHookConfig, -) { - return async (context: ApiQueryHookContext): Promise => { - try { - const shouldRun = await config.shouldRun(context) - if (!shouldRun) { - return - } - - const uuid = randomUUID() - - // Build messages using the config's buildMessages function - const messages = config.buildMessages(context) - context.queryMessageCount = messages.length - - // Use config's system prompt if provided, otherwise use context's - const systemPrompt = config.systemPrompt - ? asSystemPrompt([config.systemPrompt]) - : context.systemPrompt - - // Use config's tools preference (defaults to true = use context tools) - const useTools = config.useTools ?? true - const tools = useTools ? context.toolUseContext.options.tools : [] - - // Get model (lazy loaded) - const model = config.getModel(context) - - // Make API call - const response = await queryModelWithoutStreaming({ - messages, - systemPrompt, - thinkingConfig: { type: 'disabled' as const }, - tools, - signal: createAbortController().signal, - options: { - getToolPermissionContext: async () => { - const appState = context.toolUseContext.getAppState() - return appState.toolPermissionContext - }, - model, - toolChoice: undefined, - isNonInteractiveSession: - context.toolUseContext.options.isNonInteractiveSession, - hasAppendSystemPrompt: - !!context.toolUseContext.options.appendSystemPrompt, - temperatureOverride: 0, - agents: context.toolUseContext.options.agentDefinitions.activeAgents, - querySource: config.name, - mcpTools: [], - agentId: context.toolUseContext.agentId, - }, - }) - - // Parse response - const content = extractTextContent(response.message.content).trim() - - try { - const result = config.parseResponse(content, context) - config.logResult( - { - type: 'success', - queryName: config.name, - result, - messageId: response.message.id, - model, - uuid, - }, - context, - ) - } catch (error) { - config.logResult( - { - type: 'error', - queryName: config.name, - error: error as Error, - uuid, - }, - context, - ) - } - } catch (error) { - logError(toError(error)) - } - } -} diff --git a/hook-dump/src_utils_hooks_execAgentHook.ts b/hook-dump/src_utils_hooks_execAgentHook.ts deleted file mode 100644 index a4614e0a8c..0000000000 --- a/hook-dump/src_utils_hooks_execAgentHook.ts +++ /dev/null @@ -1,342 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execAgentHook.ts -LINES: 339 -========== -import { randomUUID } from 'crypto' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { query } from '../../query.js' -import { logEvent } from '../../services/analytics/index.js' -import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../../services/analytics/metadata.js' -import type { ToolUseContext } from '../../Tool.js' -import { type Tool, toolMatchesName } from '../../Tool.js' -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' -import { ALL_AGENT_DISALLOWED_TOOLS } from '../../tools.js' -import { asAgentId } from '../../types/ids.js' -import type { Message } from '../../types/message.js' -import { createAbortController } from '../abortController.js' -import { createAttachmentMessage } from '../attachments.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import type { HookResult } from '../hooks.js' -import { createUserMessage, handleMessageFromStream } from '../messages.js' -import { getSmallFastModel } from '../model/model.js' -import { hasPermissionsToUseTool } from '../permissions/permissions.js' -import { getAgentTranscriptPath, getTranscriptPath } from '../sessionStorage.js' -import type { AgentHook } from '../settings/types.js' -import { jsonStringify } from '../slowOperations.js' -import { asSystemPrompt } from '../systemPromptType.js' -import { - addArgumentsToPrompt, - createStructuredOutputTool, - hookResponseSchema, - registerStructuredOutputEnforcement, -} from './hookHelpers.js' -import { clearSessionHooks } from './sessionHooks.js' - -/** - * Execute an agent-based hook using a multi-turn LLM query - */ -export async function execAgentHook( - hook: AgentHook, - hookName: string, - hookEvent: HookEvent, - jsonInput: string, - signal: AbortSignal, - toolUseContext: ToolUseContext, - toolUseID: string | undefined, - // Kept for signature stability with the other exec*Hook functions. - // Was used by hook.prompt(messages) before the .transform() was removed - // (CC-79) 鈥?the only consumer of that was ExitPlanModeV2Tool's - // programmatic construction, since refactored into VerifyPlanExecutionTool. - _messages: Message[], - agentName?: string, -): Promise { - const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` - - // Get transcript path from context - const transcriptPath = toolUseContext.agentId - ? getAgentTranscriptPath(toolUseContext.agentId) - : getTranscriptPath() - const hookStartTime = Date.now() - try { - // Replace $ARGUMENTS with the JSON input - const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) - logForDebugging( - `Hooks: Processing agent hook with prompt: ${processedPrompt}`, - ) - - // Create user message directly - no need for processUserInput which would - // trigger UserPromptSubmit hooks and cause infinite recursion - const userMessage = createUserMessage({ content: processedPrompt }) - const agentMessages = [userMessage] - - logForDebugging( - `Hooks: Starting agent query with ${agentMessages.length} messages`, - ) - - // Setup timeout and combine with parent signal - const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 60000 - const hookAbortController = createAbortController() - - // Combine parent signal with timeout, and have it abort our controller - const { signal: parentTimeoutSignal, cleanup: cleanupCombinedSignal } = - createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) - const onParentTimeout = () => hookAbortController.abort() - parentTimeoutSignal.addEventListener('abort', onParentTimeout) - - // Combined signal is just our controller's signal now - const combinedSignal = hookAbortController.signal - - try { - // Create StructuredOutput tool with our schema - const structuredOutputTool = createStructuredOutputTool() - - // Filter out any existing StructuredOutput tool to avoid duplicates with different schemas - // (e.g., when parent context has a StructuredOutput tool from --json-schema flag) - const filteredTools = toolUseContext.options.tools.filter( - tool => !toolMatchesName(tool, SYNTHETIC_OUTPUT_TOOL_NAME), - ) - - // Use all available tools plus our structured output tool - // Filter out disallowed agent tools to prevent stop hook agents from spawning subagents - // or entering plan mode, and filter out duplicate StructuredOutput tools - const tools: Tool[] = [ - ...filteredTools.filter( - tool => !ALL_AGENT_DISALLOWED_TOOLS.has(tool.name), - ), - structuredOutputTool, - ] - - const systemPrompt = asSystemPrompt([ - `You are verifying a stop condition in Claude Code. Your task is to verify that the agent completed the given plan. The conversation transcript is available at: ${transcriptPath}\nYou can read this file to analyze the conversation history if needed. - -Use the available tools to inspect the codebase and verify the condition. -Use as few steps as possible - be efficient and direct. - -When done, return your result using the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool with: -- ok: true if the condition is met -- ok: false with reason if the condition is not met`, - ]) - - const model = hook.model ?? getSmallFastModel() - const MAX_AGENT_TURNS = 50 - - // Create unique agentId for this hook agent - const hookAgentId = asAgentId(`hook-agent-${randomUUID()}`) - - // Create a modified toolUseContext for the agent - const agentToolUseContext: ToolUseContext = { - ...toolUseContext, - agentId: hookAgentId, - abortController: hookAbortController, - options: { - ...toolUseContext.options, - tools, - mainLoopModel: model, - isNonInteractiveSession: true, - thinkingConfig: { type: 'disabled' as const }, - }, - setInProgressToolUseIDs: () => {}, - getAppState() { - const appState = toolUseContext.getAppState() - // Add session rule to allow reading transcript file - const existingSessionRules = - appState.toolPermissionContext.alwaysAllowRules.session ?? [] - return { - ...appState, - toolPermissionContext: { - ...appState.toolPermissionContext, - mode: 'dontAsk' as const, - alwaysAllowRules: { - ...appState.toolPermissionContext.alwaysAllowRules, - session: [...existingSessionRules, `Read(/${transcriptPath})`], - }, - }, - } - }, - } - - // Register a session-level stop hook to enforce structured output - registerStructuredOutputEnforcement( - toolUseContext.setAppState, - hookAgentId, - ) - - let structuredOutputResult: { ok: boolean; reason?: string } | null = null - let turnCount = 0 - let hitMaxTurns = false - - // Use query() for multi-turn execution - for await (const message of query({ - messages: agentMessages, - systemPrompt, - userContext: {}, - systemContext: {}, - canUseTool: hasPermissionsToUseTool, - toolUseContext: agentToolUseContext, - querySource: 'hook_agent', - })) { - // Process stream events to update response length in the spinner - handleMessageFromStream( - message, - () => {}, // onMessage - we handle messages below - newContent => - toolUseContext.setResponseLength( - length => length + newContent.length, - ), - toolUseContext.setStreamMode ?? (() => {}), - () => {}, // onStreamingToolUses - not needed for hooks - ) - - // Skip streaming events for further processing - if ( - message.type === 'stream_event' || - message.type === 'stream_request_start' - ) { - continue - } - - // Count assistant turns - if (message.type === 'assistant') { - turnCount++ - - // Check if we've hit the turn limit - if (turnCount >= MAX_AGENT_TURNS) { - hitMaxTurns = true - logForDebugging( - `Hooks: Agent turn ${turnCount} hit max turns, aborting`, - ) - hookAbortController.abort() - break - } - } - - // Check for structured output in attachments - if ( - message.type === 'attachment' && - message.attachment.type === 'structured_output' - ) { - const parsed = hookResponseSchema().safeParse(message.attachment.data) - if (parsed.success) { - structuredOutputResult = parsed.data - logForDebugging( - `Hooks: Got structured output: ${jsonStringify(structuredOutputResult)}`, - ) - // Got structured output, abort and exit - hookAbortController.abort() - break - } - } - } - - parentTimeoutSignal.removeEventListener('abort', onParentTimeout) - cleanupCombinedSignal() - - // Clean up the session hook we registered for this agent - clearSessionHooks(toolUseContext.setAppState, hookAgentId) - - // Check if we got a result - if (!structuredOutputResult) { - // If we hit max turns, just log and return cancelled (no UI message) - if (hitMaxTurns) { - logForDebugging( - `Hooks: Agent hook did not complete within ${MAX_AGENT_TURNS} turns`, - ) - logEvent('tengu_agent_stop_hook_max_turns', { - durationMs: Date.now() - hookStartTime, - turnCount, - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'cancelled', - } - } - - // For other cases (e.g., agent finished without calling structured output tool), - // just log and return cancelled (don't show error to user) - logForDebugging(`Hooks: Agent hook did not return structured output`) - logEvent('tengu_agent_stop_hook_error', { - durationMs: Date.now() - hookStartTime, - turnCount, - errorType: 1, // 1 = no structured output - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'cancelled', - } - } - - // Return result based on structured output - if (!structuredOutputResult.ok) { - logForDebugging( - `Hooks: Agent hook condition was not met: ${structuredOutputResult.reason}`, - ) - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError: `Agent hook condition was not met: ${structuredOutputResult.reason}`, - command: hook.prompt, - }, - } - } - - // Condition was met - logForDebugging(`Hooks: Agent hook condition was met`) - logEvent('tengu_agent_stop_hook_success', { - durationMs: Date.now() - hookStartTime, - turnCount, - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'success', - message: createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - content: '', - }), - } - } catch (error) { - parentTimeoutSignal.removeEventListener('abort', onParentTimeout) - cleanupCombinedSignal() - - if (combinedSignal.aborted) { - return { - hook, - outcome: 'cancelled', - } - } - throw error - } - } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: Agent hook error: ${errorMsg}`) - logEvent('tengu_agent_stop_hook_error', { - durationMs: Date.now() - hookStartTime, - errorType: 2, // 2 = general error - agentName: - agentName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Error executing agent hook: ${errorMsg}`, - stdout: '', - exitCode: 1, - }), - } - } -} diff --git a/hook-dump/src_utils_hooks_execHttpHook.ts b/hook-dump/src_utils_hooks_execHttpHook.ts deleted file mode 100644 index 5975133fe7..0000000000 --- a/hook-dump/src_utils_hooks_execHttpHook.ts +++ /dev/null @@ -1,245 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execHttpHook.ts -LINES: 242 -========== -import axios from 'axios' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import { getProxyUrl, shouldBypassProxy } from '../proxy.js' -// Import as namespace so spyOn works in tests (direct imports bypass spies) -import * as settingsModule from '../settings/settings.js' -import type { HttpHook } from '../settings/types.js' -import { ssrfGuardedLookup } from './ssrfGuard.js' - -const DEFAULT_HTTP_HOOK_TIMEOUT_MS = 10 * 60 * 1000 // 10 minutes (matches TOOL_HOOK_EXECUTION_TIMEOUT_MS) - -/** - * Get the sandbox proxy config for routing HTTP hook requests through the - * sandbox network proxy when sandboxing is enabled. - * - * Uses dynamic import to avoid a static import cycle - * (sandbox-adapter -> settings -> ... -> hooks -> execHttpHook). - */ -async function getSandboxProxyConfig(): Promise< - { host: string; port: number; protocol: string } | undefined -> { - const { SandboxManager } = await import('../sandbox/sandbox-adapter.js') - - if (!SandboxManager.isSandboxingEnabled()) { - return undefined - } - - // Wait for the sandbox network proxy to finish initializing. In REPL mode, - // SandboxManager.initialize() is fire-and-forget so the proxy may not be - // ready yet when the first hook fires. - await SandboxManager.waitForNetworkInitialization() - - const proxyPort = SandboxManager.getProxyPort() - if (!proxyPort) { - return undefined - } - - return { host: '127.0.0.1', port: proxyPort, protocol: 'http' } -} - -/** - * Read HTTP hook allowlist restrictions from merged settings (all sources). - * Follows the allowedMcpServers precedent: arrays concatenate across sources. - * When allowManagedHooksOnly is set in managed settings, only admin-defined - * hooks run anyway, so no separate lock-down boolean is needed here. - */ -function getHttpHookPolicy(): { - allowedUrls: string[] | undefined - allowedEnvVars: string[] | undefined -} { - const settings = settingsModule.getInitialSettings() - return { - allowedUrls: settings.allowedHttpHookUrls, - allowedEnvVars: settings.httpHookAllowedEnvVars, - } -} - -/** - * Match a URL against a pattern with * as a wildcard (any characters). - * Same semantics as the MCP server allowlist patterns. - */ -function urlMatchesPattern(url: string, pattern: string): boolean { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') - const regexStr = escaped.replace(/\*/g, '.*') - return new RegExp(`^${regexStr}$`).test(url) -} - -/** - * Strip CR, LF, and NUL bytes from a header value to prevent HTTP header - * injection (CRLF injection) via env var values or hook-configured header - * templates. A malicious env var like "token\r\nX-Evil: 1" would otherwise - * inject a second header into the request. - */ -function sanitizeHeaderValue(value: string): string { - // eslint-disable-next-line no-control-regex - return value.replace(/[\r\n\x00]/g, '') -} - -/** - * Interpolate $VAR_NAME and ${VAR_NAME} patterns in a string using process.env, - * but only for variable names present in the allowlist. References to variables - * not in the allowlist are replaced with empty strings to prevent exfiltration - * of secrets via project-configured HTTP hooks. - * - * The result is sanitized to strip CR/LF/NUL bytes to prevent header injection. - */ -function interpolateEnvVars( - value: string, - allowedEnvVars: ReadonlySet, -): string { - const interpolated = value.replace( - /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g, - (_, braced, unbraced) => { - const varName = braced ?? unbraced - if (!allowedEnvVars.has(varName)) { - logForDebugging( - `Hooks: env var $${varName} not in allowedEnvVars, skipping interpolation`, - { level: 'warn' }, - ) - return '' - } - return process.env[varName] ?? '' - }, - ) - return sanitizeHeaderValue(interpolated) -} - -/** - * Execute an HTTP hook by POSTing the hook input JSON to the configured URL. - * Returns the raw response for the caller to interpret. - * - * When sandboxing is enabled, requests are routed through the sandbox network - * proxy which enforces the domain allowlist. The proxy returns HTTP 403 for - * blocked domains. - * - * Header values support $VAR_NAME and ${VAR_NAME} env var interpolation so that - * secrets (e.g. "Authorization: Bearer $MY_TOKEN") are not stored in settings.json. - * Only env vars explicitly listed in the hook's `allowedEnvVars` array are resolved; - * all other references are replaced with empty strings. - */ -export async function execHttpHook( - hook: HttpHook, - _hookEvent: HookEvent, - jsonInput: string, - signal?: AbortSignal, -): Promise<{ - ok: boolean - statusCode?: number - body: string - error?: string - aborted?: boolean -}> { - // Enforce URL allowlist before any I/O. Follows allowedMcpServers semantics: - // undefined 鈫?no restriction; [] 鈫?block all; non-empty 鈫?must match a pattern. - const policy = getHttpHookPolicy() - if (policy.allowedUrls !== undefined) { - const matched = policy.allowedUrls.some(p => urlMatchesPattern(hook.url, p)) - if (!matched) { - const msg = `HTTP hook blocked: ${hook.url} does not match any pattern in allowedHttpHookUrls` - logForDebugging(msg, { level: 'warn' }) - return { ok: false, body: '', error: msg } - } - } - - const timeoutMs = hook.timeout - ? hook.timeout * 1000 - : DEFAULT_HTTP_HOOK_TIMEOUT_MS - - const { signal: combinedSignal, cleanup } = createCombinedAbortSignal( - signal, - { timeoutMs }, - ) - - try { - // Build headers with env var interpolation in values - const headers: Record = { - 'Content-Type': 'application/json', - } - if (hook.headers) { - // Intersect hook's allowedEnvVars with policy allowlist when policy is set - const hookVars = hook.allowedEnvVars ?? [] - const effectiveVars = - policy.allowedEnvVars !== undefined - ? hookVars.filter(v => policy.allowedEnvVars!.includes(v)) - : hookVars - const allowedEnvVars = new Set(effectiveVars) - for (const [name, value] of Object.entries(hook.headers)) { - headers[name] = interpolateEnvVars(value, allowedEnvVars) - } - } - - // Route through sandbox network proxy when available. The proxy enforces - // the domain allowlist and returns 403 for blocked domains. - const sandboxProxy = await getSandboxProxyConfig() - - // Detect env var proxy (HTTP_PROXY / HTTPS_PROXY, respecting NO_PROXY). - // When set, configureGlobalAgents() has already installed a request - // interceptor that sets httpsAgent to an HttpsProxyAgent 鈥?the proxy - // handles DNS for the target. Skip the SSRF guard in that case, same - // as we do for the sandbox proxy, so that we don't accidentally block - // a corporate proxy sitting on a private IP (e.g. 10.0.0.1:3128). - const envProxyActive = - !sandboxProxy && - getProxyUrl() !== undefined && - !shouldBypassProxy(hook.url) - - if (sandboxProxy) { - logForDebugging( - `Hooks: HTTP hook POST to ${hook.url} (via sandbox proxy :${sandboxProxy.port})`, - ) - } else if (envProxyActive) { - logForDebugging( - `Hooks: HTTP hook POST to ${hook.url} (via env-var proxy)`, - ) - } else { - logForDebugging(`Hooks: HTTP hook POST to ${hook.url}`) - } - - const response = await axios.post(hook.url, jsonInput, { - headers, - signal: combinedSignal, - responseType: 'text', - validateStatus: () => true, - maxRedirects: 0, - // Explicit false prevents axios's own env-var proxy detection; when an - // env-var proxy is configured, the global axios interceptor installed - // by configureGlobalAgents() handles it via httpsAgent instead. - proxy: sandboxProxy ?? false, - // SSRF guard: validate resolved IPs, block private/link-local ranges - // (but allow loopback for local dev). Skipped when any proxy is in - // use 鈥?the proxy performs DNS for the target, and applying the - // guard would instead validate the proxy's own IP, breaking - // connections to corporate proxies on private networks. - lookup: sandboxProxy || envProxyActive ? undefined : ssrfGuardedLookup, - }) - - cleanup() - - const body = response.data ?? '' - logForDebugging( - `Hooks: HTTP hook response status ${response.status}, body length ${body.length}`, - ) - - return { - ok: response.status >= 200 && response.status < 300, - statusCode: response.status, - body, - } - } catch (error) { - cleanup() - - if (combinedSignal.aborted) { - return { ok: false, body: '', aborted: true } - } - - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: HTTP hook error: ${errorMsg}`, { level: 'error' }) - return { ok: false, body: '', error: errorMsg } - } -} diff --git a/hook-dump/src_utils_hooks_execPromptHook.ts b/hook-dump/src_utils_hooks_execPromptHook.ts deleted file mode 100644 index cfb147e5c3..0000000000 --- a/hook-dump/src_utils_hooks_execPromptHook.ts +++ /dev/null @@ -1,257 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\execPromptHook.ts -LINES: 254 -========== -import { randomUUID } from 'crypto' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { queryModelWithoutStreaming } from '../../services/api/claude.js' -import type { ToolUseContext } from '../../Tool.js' -import type { Message } from '../../types/message.js' -import { isGoalPromptHookCommand } from '../../goals/goalState.js' -import { createAttachmentMessage } from '../attachments.js' -import { createCombinedAbortSignal } from '../combinedAbortSignal.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import type { HookResult } from '../hooks.js' -import { safeParseJSON } from '../json.js' -import { createUserMessage, extractTextContent } from '../messages.js' -import { getSmallFastModel } from '../model/model.js' -import type { PromptHook } from '../settings/types.js' -import { asSystemPrompt } from '../systemPromptType.js' -import { addArgumentsToPrompt, hookResponseSchema } from './hookHelpers.js' - -function goalHookFailureResult( - hook: PromptHook, - reason: string, -): HookResult | null { - if (!isGoalPromptHookCommand(hook.prompt)) return null - - const blockingError = `Goal evaluator failed: ${reason}. Treat the goal as incomplete and continue working toward it.` - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError, - command: hook.prompt, - }, - preventContinuation: true, - stopReason: blockingError, - } -} - -/** - * Execute a prompt-based hook using an LLM - */ -export async function execPromptHook( - hook: PromptHook, - hookName: string, - hookEvent: HookEvent, - jsonInput: string, - signal: AbortSignal, - toolUseContext: ToolUseContext, - messages?: Message[], - toolUseID?: string, -): Promise { - // Use provided toolUseID or generate a new one - const effectiveToolUseID = toolUseID || `hook-${randomUUID()}` - try { - // Replace $ARGUMENTS with the JSON input - const processedPrompt = addArgumentsToPrompt(hook.prompt, jsonInput) - logForDebugging( - `Hooks: Processing prompt hook with prompt: ${processedPrompt}`, - ) - - // Create user message directly - no need for processUserInput which would - // trigger UserPromptSubmit hooks and cause infinite recursion - const userMessage = createUserMessage({ content: processedPrompt }) - - // Prepend conversation history if provided - const messagesToQuery = - messages && messages.length > 0 - ? [...messages, userMessage] - : [userMessage] - - logForDebugging( - `Hooks: Querying model with ${messagesToQuery.length} messages`, - ) - - // Query the model with Haiku - const hookTimeoutMs = hook.timeout ? hook.timeout * 1000 : 30000 - - // Combined signal: aborts if either the hook signal or timeout triggers - const { signal: combinedSignal, cleanup: cleanupSignal } = - createCombinedAbortSignal(signal, { timeoutMs: hookTimeoutMs }) - - try { - const response = await queryModelWithoutStreaming({ - messages: messagesToQuery, - systemPrompt: asSystemPrompt([ - `You are evaluating a hook in Claude Code. - -Your response must be a JSON object matching one of the following schemas: -1. If the condition is met, return: {"ok": true} -2. If the condition is not met, return: {"ok": false, "reason": "Reason for why it is not met"}`, - ]), - thinkingConfig: { type: 'disabled' as const }, - tools: toolUseContext.options.tools, - signal: combinedSignal, - options: { - async getToolPermissionContext() { - const appState = toolUseContext.getAppState() - return appState.toolPermissionContext - }, - model: hook.model ?? getSmallFastModel(), - toolChoice: undefined, - isNonInteractiveSession: true, - hasAppendSystemPrompt: false, - agents: [], - querySource: 'hook_prompt', - mcpTools: [], - agentId: toolUseContext.agentId, - outputFormat: { - type: 'json_schema', - schema: { - type: 'object', - properties: { - ok: { type: 'boolean' }, - reason: { type: 'string' }, - }, - required: ['ok'], - additionalProperties: false, - }, - }, - }, - }) - - cleanupSignal() - - // Extract text content from response - const content = extractTextContent(response.message.content) - - // Update response length for spinner display - toolUseContext.setResponseLength(length => length + content.length) - - const fullResponse = content.trim() - logForDebugging(`Hooks: Model response: ${fullResponse}`) - - const json = safeParseJSON(fullResponse) - if (!json) { - logForDebugging( - `Hooks: error parsing response as JSON: ${fullResponse}`, - ) - const goalFailure = goalHookFailureResult( - hook, - 'response was not valid JSON', - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: 'JSON validation failed', - stdout: fullResponse, - exitCode: 1, - }), - } - } - - const parsed = hookResponseSchema().safeParse(json) - if (!parsed.success) { - logForDebugging( - `Hooks: model response does not conform to expected schema: ${parsed.error.message}`, - ) - const goalFailure = goalHookFailureResult( - hook, - `response did not match the expected schema (${parsed.error.message})`, - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Schema validation failed: ${parsed.error.message}`, - stdout: fullResponse, - exitCode: 1, - }), - } - } - - // Failed to meet condition - if (!parsed.data.ok) { - logForDebugging( - `Hooks: Prompt hook condition was not met: ${parsed.data.reason}`, - ) - return { - hook, - outcome: 'blocking', - blockingError: { - blockingError: `Prompt hook condition was not met: ${parsed.data.reason}`, - command: hook.prompt, - }, - preventContinuation: true, - stopReason: parsed.data.reason, - } - } - - // Condition was met - logForDebugging(`Hooks: Prompt hook condition was met`) - return { - hook, - outcome: 'success', - message: createAttachmentMessage({ - type: 'hook_success', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - content: '', - }), - } - } catch (error) { - cleanupSignal() - - if (combinedSignal.aborted) { - const goalFailure = signal.aborted - ? null - : goalHookFailureResult(hook, 'evaluation timed out') - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'cancelled', - } - } - throw error - } - } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging(`Hooks: Prompt hook error: ${errorMsg}`) - const goalFailure = goalHookFailureResult( - hook, - errorMsg || 'evaluation failed', - ) - if (goalFailure) return goalFailure - - return { - hook, - outcome: 'non_blocking_error', - message: createAttachmentMessage({ - type: 'hook_non_blocking_error', - hookName, - toolUseID: effectiveToolUseID, - hookEvent, - stderr: `Error executing prompt hook: ${errorMsg}`, - stdout: '', - exitCode: 1, - }), - } - } -} diff --git a/hook-dump/src_utils_hooks_fileChangedWatcher.ts b/hook-dump/src_utils_hooks_fileChangedWatcher.ts deleted file mode 100644 index 8d5dec235f..0000000000 --- a/hook-dump/src_utils_hooks_fileChangedWatcher.ts +++ /dev/null @@ -1,194 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\fileChangedWatcher.ts -LINES: 191 -========== -import chokidar, { type FSWatcher } from 'chokidar' -import { isAbsolute, join } from 'path' -import { registerCleanup } from '../cleanupRegistry.js' -import { logForDebugging } from '../debug.js' -import { errorMessage } from '../errors.js' -import { - executeCwdChangedHooks, - executeFileChangedHooks, - type HookOutsideReplResult, -} from '../hooks.js' -import { clearCwdEnvFiles } from '../sessionEnvironment.js' -import { getHooksConfigFromSnapshot } from './hooksConfigSnapshot.js' - -let watcher: FSWatcher | null = null -let currentCwd: string -let dynamicWatchPaths: string[] = [] -let dynamicWatchPathsSorted: string[] = [] -let initialized = false -let hasEnvHooks = false -let notifyCallback: ((text: string, isError: boolean) => void) | null = null - -export function setEnvHookNotifier( - cb: ((text: string, isError: boolean) => void) | null, -): void { - notifyCallback = cb -} - -export function initializeFileChangedWatcher(cwd: string): void { - if (initialized) return - initialized = true - currentCwd = cwd - - const config = getHooksConfigFromSnapshot() - hasEnvHooks = - (config?.CwdChanged?.length ?? 0) > 0 || - (config?.FileChanged?.length ?? 0) > 0 - - if (hasEnvHooks) { - registerCleanup(async () => dispose()) - } - - const paths = resolveWatchPaths(config) - if (paths.length === 0) return - - startWatching(paths) -} - -function resolveWatchPaths( - config?: ReturnType, -): string[] { - const matchers = (config ?? getHooksConfigFromSnapshot())?.FileChanged ?? [] - - // Matcher field: filenames to watch in cwd, pipe-separated (e.g. ".envrc|.env") - const staticPaths: string[] = [] - for (const m of matchers) { - if (!m.matcher) continue - for (const name of m.matcher.split('|').map(s => s.trim())) { - if (!name) continue - staticPaths.push(isAbsolute(name) ? name : join(currentCwd, name)) - } - } - - // Combine static matcher paths with dynamic paths from hook output - return [...new Set([...staticPaths, ...dynamicWatchPaths])] -} - -function startWatching(paths: string[]): void { - logForDebugging(`FileChanged: watching ${paths.length} paths`) - watcher = chokidar.watch(paths, { - persistent: true, - ignoreInitial: true, - awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 200 }, - ignorePermissionErrors: true, - }) - watcher.on('change', p => handleFileEvent(p, 'change')) - watcher.on('add', p => handleFileEvent(p, 'add')) - watcher.on('unlink', p => handleFileEvent(p, 'unlink')) -} - -function handleFileEvent( - path: string, - event: 'change' | 'add' | 'unlink', -): void { - logForDebugging(`FileChanged: ${event} ${path}`) - void executeFileChangedHooks(path, event) - .then(({ results, watchPaths, systemMessages }) => { - if (watchPaths.length > 0) { - updateWatchPaths(watchPaths) - } - for (const msg of systemMessages) { - notifyCallback?.(msg, false) - } - for (const r of results) { - if (!r.succeeded && r.output) { - notifyCallback?.(r.output, true) - } - } - }) - .catch(e => { - const msg = errorMessage(e) - logForDebugging(`FileChanged hook failed: ${msg}`, { - level: 'error', - }) - notifyCallback?.(msg, true) - }) -} - -export function updateWatchPaths(paths: string[]): void { - if (!initialized) return - const sorted = paths.slice().sort() - if ( - sorted.length === dynamicWatchPathsSorted.length && - sorted.every((p, i) => p === dynamicWatchPathsSorted[i]) - ) { - return - } - dynamicWatchPaths = paths - dynamicWatchPathsSorted = sorted - restartWatching() -} - -function restartWatching(): void { - if (watcher) { - void watcher.close() - watcher = null - } - const paths = resolveWatchPaths() - if (paths.length > 0) { - startWatching(paths) - } -} - -export async function onCwdChangedForHooks( - oldCwd: string, - newCwd: string, -): Promise { - if (oldCwd === newCwd) return - - // Re-evaluate from the current snapshot so mid-session hook changes are picked up - const config = getHooksConfigFromSnapshot() - const currentHasEnvHooks = - (config?.CwdChanged?.length ?? 0) > 0 || - (config?.FileChanged?.length ?? 0) > 0 - if (!currentHasEnvHooks) return - currentCwd = newCwd - - await clearCwdEnvFiles() - const hookResult = await executeCwdChangedHooks(oldCwd, newCwd).catch(e => { - const msg = errorMessage(e) - logForDebugging(`CwdChanged hook failed: ${msg}`, { - level: 'error', - }) - notifyCallback?.(msg, true) - return { - results: [] as HookOutsideReplResult[], - watchPaths: [] as string[], - systemMessages: [] as string[], - } - }) - dynamicWatchPaths = hookResult.watchPaths - dynamicWatchPathsSorted = hookResult.watchPaths.slice().sort() - for (const msg of hookResult.systemMessages) { - notifyCallback?.(msg, false) - } - for (const r of hookResult.results) { - if (!r.succeeded && r.output) { - notifyCallback?.(r.output, true) - } - } - - // Re-resolve matcher paths against the new cwd - if (initialized) { - restartWatching() - } -} - -function dispose(): void { - if (watcher) { - void watcher.close() - watcher = null - } - dynamicWatchPaths = [] - dynamicWatchPathsSorted = [] - initialized = false - hasEnvHooks = false - notifyCallback = null -} - -export function resetFileChangedWatcherForTesting(): void { - dispose() -} diff --git a/hook-dump/src_utils_hooks_hookEvents.ts b/hook-dump/src_utils_hooks_hookEvents.ts deleted file mode 100644 index 5474812689..0000000000 --- a/hook-dump/src_utils_hooks_hookEvents.ts +++ /dev/null @@ -1,195 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hookEvents.ts -LINES: 192 -========== -/** - * Hook event system for broadcasting hook execution events. - * - * This module provides a generic event system that is separate from the - * main message stream. Handlers can register to receive events and decide - * what to do with them (e.g., convert to SDK messages, log, etc.). - */ - -import { HOOK_EVENTS } from 'src/entrypoints/sdk/coreTypes.js' - -import { logForDebugging } from '../debug.js' - -/** - * Hook events that are always emitted regardless of the includeHookEvents - * option. These are low-noise lifecycle events that were in the original - * allowlist and are backwards-compatible. - */ -const ALWAYS_EMITTED_HOOK_EVENTS = ['SessionStart', 'Setup'] as const - -const MAX_PENDING_EVENTS = 100 - -export type HookStartedEvent = { - type: 'started' - hookId: string - hookName: string - hookEvent: string -} - -export type HookProgressEvent = { - type: 'progress' - hookId: string - hookName: string - hookEvent: string - stdout: string - stderr: string - output: string -} - -export type HookResponseEvent = { - type: 'response' - hookId: string - hookName: string - hookEvent: string - output: string - stdout: string - stderr: string - exitCode?: number - outcome: 'success' | 'error' | 'cancelled' -} - -export type HookExecutionEvent = - | HookStartedEvent - | HookProgressEvent - | HookResponseEvent -export type HookEventHandler = (event: HookExecutionEvent) => void - -const pendingEvents: HookExecutionEvent[] = [] -let eventHandler: HookEventHandler | null = null -let allHookEventsEnabled = false - -export function registerHookEventHandler( - handler: HookEventHandler | null, -): void { - eventHandler = handler - if (handler && pendingEvents.length > 0) { - for (const event of pendingEvents.splice(0)) { - handler(event) - } - } -} - -function emit(event: HookExecutionEvent): void { - if (eventHandler) { - eventHandler(event) - } else { - pendingEvents.push(event) - if (pendingEvents.length > MAX_PENDING_EVENTS) { - pendingEvents.shift() - } - } -} - -function shouldEmit(hookEvent: string): boolean { - if ((ALWAYS_EMITTED_HOOK_EVENTS as readonly string[]).includes(hookEvent)) { - return true - } - return ( - allHookEventsEnabled && - (HOOK_EVENTS as readonly string[]).includes(hookEvent) - ) -} - -export function emitHookStarted( - hookId: string, - hookName: string, - hookEvent: string, -): void { - if (!shouldEmit(hookEvent)) return - - emit({ - type: 'started', - hookId, - hookName, - hookEvent, - }) -} - -export function emitHookProgress(data: { - hookId: string - hookName: string - hookEvent: string - stdout: string - stderr: string - output: string -}): void { - if (!shouldEmit(data.hookEvent)) return - - emit({ - type: 'progress', - ...data, - }) -} - -export function startHookProgressInterval(params: { - hookId: string - hookName: string - hookEvent: string - getOutput: () => Promise<{ stdout: string; stderr: string; output: string }> - intervalMs?: number -}): () => void { - if (!shouldEmit(params.hookEvent)) return () => {} - - let lastEmittedOutput = '' - const interval = setInterval(() => { - void params.getOutput().then(({ stdout, stderr, output }) => { - if (output === lastEmittedOutput) return - lastEmittedOutput = output - emitHookProgress({ - hookId: params.hookId, - hookName: params.hookName, - hookEvent: params.hookEvent, - stdout, - stderr, - output, - }) - }) - }, params.intervalMs ?? 1000) - interval.unref() - - return () => clearInterval(interval) -} - -export function emitHookResponse(data: { - hookId: string - hookName: string - hookEvent: string - output: string - stdout: string - stderr: string - exitCode?: number - outcome: 'success' | 'error' | 'cancelled' -}): void { - // Always log full hook output to debug log for verbose mode debugging - const outputToLog = data.stdout || data.stderr || data.output - if (outputToLog) { - logForDebugging( - `Hook ${data.hookName} (${data.hookEvent}) ${data.outcome}:\n${outputToLog}`, - ) - } - - if (!shouldEmit(data.hookEvent)) return - - emit({ - type: 'response', - ...data, - }) -} - -/** - * Enable emission of all hook event types (beyond SessionStart and Setup). - * Called when the SDK `includeHookEvents` option is set or when running - * in CLAUDE_CODE_REMOTE mode. - */ -export function setAllHookEventsEnabled(enabled: boolean): void { - allHookEventsEnabled = enabled -} - -export function clearHookEventState(): void { - eventHandler = null - pendingEvents.length = 0 - allHookEventsEnabled = false -} diff --git a/hook-dump/src_utils_hooks_hookHelpers.ts b/hook-dump/src_utils_hooks_hookHelpers.ts deleted file mode 100644 index 1326d115df..0000000000 --- a/hook-dump/src_utils_hooks_hookHelpers.ts +++ /dev/null @@ -1,86 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hookHelpers.ts -LINES: 83 -========== -import { z } from 'zod/v4' -import type { Tool } from '../../Tool.js' -import { - SYNTHETIC_OUTPUT_TOOL_NAME, - SyntheticOutputTool, -} from '../../tools/SyntheticOutputTool/SyntheticOutputTool.js' -import { substituteArguments } from '../argumentSubstitution.js' -import { lazySchema } from '../lazySchema.js' -import type { SetAppState } from '../messageQueueManager.js' -import { hasSuccessfulToolCall } from '../messages.js' -import { addFunctionHook } from './sessionHooks.js' - -/** - * Schema for hook responses (shared by prompt and agent hooks) - */ -export const hookResponseSchema = lazySchema(() => - z.object({ - ok: z.boolean().describe('Whether the condition was met'), - reason: z - .string() - .describe('Reason, if the condition was not met') - .optional(), - }), -) - -/** - * Add hook input JSON to prompt, either replacing $ARGUMENTS placeholder or appending. - * Also supports indexed arguments like $ARGUMENTS[0], $ARGUMENTS[1], or shorthand $0, $1, etc. - */ -export function addArgumentsToPrompt( - prompt: string, - jsonInput: string, -): string { - return substituteArguments(prompt, jsonInput) -} - -/** - * Create a StructuredOutput tool configured for hook responses. - * Reusable by agent hooks and background verification. - */ -export function createStructuredOutputTool(): Tool { - return { - ...SyntheticOutputTool, - inputSchema: hookResponseSchema(), - inputJSONSchema: { - type: 'object', - properties: { - ok: { - type: 'boolean', - description: 'Whether the condition was met', - }, - reason: { - type: 'string', - description: 'Reason, if the condition was not met', - }, - }, - required: ['ok'], - additionalProperties: false, - }, - async prompt(): Promise { - return `Use this tool to return your verification result. You MUST call this tool exactly once at the end of your response.` - }, - } -} - -/** - * Register a function hook that enforces structured output via SyntheticOutputTool. - * Used by ask.tsx, execAgentHook.ts, and background verification. - */ -export function registerStructuredOutputEnforcement( - setAppState: SetAppState, - sessionId: string, -): void { - addFunctionHook( - setAppState, - sessionId, - 'Stop', - '', // No matcher - applies to all stops - messages => hasSuccessfulToolCall(messages, SYNTHETIC_OUTPUT_TOOL_NAME), - `You MUST call the ${SYNTHETIC_OUTPUT_TOOL_NAME} tool to complete this request. Call this tool now.`, - { timeout: 5000 }, - ) -} diff --git a/hook-dump/src_utils_hooks_hooksConfigManager.ts b/hook-dump/src_utils_hooks_hooksConfigManager.ts deleted file mode 100644 index 10615ea57a..0000000000 --- a/hook-dump/src_utils_hooks_hooksConfigManager.ts +++ /dev/null @@ -1,403 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigManager.ts -LINES: 400 -========== -import memoize from 'lodash-es/memoize.js' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { getRegisteredHooks } from '../../bootstrap/state.js' -import type { AppState } from '../../state/AppState.js' -import { - getAllHooks, - type IndividualHookConfig, - sortMatchersByPriority, -} from './hooksSettings.js' - -export type MatcherMetadata = { - fieldToMatch: string - values: string[] -} - -export type HookEventMetadata = { - summary: string - description: string - matcherMetadata?: MatcherMetadata -} - -// Hook event metadata configuration. -// Resolver uses sorted-joined string key so that callers passing a fresh -// toolNames array each render (e.g. HooksConfigMenu) hit the cache instead -// of leaking a new entry per call. -export const getHookEventMetadata = memoize( - function (toolNames: string[]): Record { - return { - PreToolUse: { - summary: 'Before tool execution', - description: - 'Input to command is JSON of tool call arguments.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and block tool call\nOther exit codes - show stderr to user only but continue with tool call', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PostToolUse: { - summary: 'After tool execution', - description: - 'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PostToolUseFailure: { - summary: 'After tool execution fails', - description: - 'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nExit code 2 - show stderr to model immediately\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - PermissionDenied: { - summary: 'After auto mode classifier denies a tool call', - description: - 'Input to command is JSON with tool_name, tool_input, tool_use_id, and reason.\nReturn {"hookSpecificOutput":{"hookEventName":"PermissionDenied","retry":true}} to tell the model it may retry.\nExit code 0 - stdout shown in transcript mode (ctrl+o)\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - Notification: { - summary: 'When notifications are sent', - description: - 'Input to command is JSON with notification message and type.\nExit code 0 - stdout/stderr not shown\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'notification_type', - values: [ - 'permission_prompt', - 'idle_prompt', - 'auth_success', - 'elicitation_dialog', - 'elicitation_complete', - 'elicitation_response', - ], - }, - }, - UserPromptSubmit: { - summary: 'When the user submits a prompt', - description: - 'Input to command is JSON with original user prompt text.\nExit code 0 - stdout shown to Claude\nExit code 2 - block processing, erase original prompt, and show stderr to user only\nOther exit codes - show stderr to user only', - }, - SessionStart: { - summary: 'When a new session is started', - description: - 'Input to command is JSON with session start source.\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'source', - values: ['startup', 'resume', 'clear', 'compact'], - }, - }, - Stop: { - summary: 'Right before Claude concludes its response', - description: - 'Exit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and continue conversation\nOther exit codes - show stderr to user only', - }, - StopFailure: { - summary: 'When the turn ends due to an API error', - description: - 'Fires instead of Stop when an API error (rate limit, auth failure, etc.) ended the turn. Fire-and-forget 鈥?hook output and exit codes are ignored.', - matcherMetadata: { - fieldToMatch: 'error', - values: [ - 'rate_limit', - 'authentication_failed', - 'billing_error', - 'invalid_request', - 'server_error', - 'max_output_tokens', - 'unknown', - ], - }, - }, - SubagentStart: { - summary: 'When a subagent (Agent tool call) is started', - description: - 'Input to command is JSON with agent_id and agent_type.\nExit code 0 - stdout shown to subagent\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'agent_type', - values: [], // Will be populated with available agent types - }, - }, - SubagentStop: { - summary: - 'Right before a subagent (Agent tool call) concludes its response', - description: - 'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to subagent and continue having it run\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'agent_type', - values: [], // Will be populated with available agent types - }, - }, - PreCompact: { - summary: 'Before conversation compaction', - description: - 'Input to command is JSON with compaction details.\nExit code 0 - stdout appended as custom compact instructions\nExit code 2 - block compaction\nOther exit codes - show stderr to user only but continue with compaction', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['manual', 'auto'], - }, - }, - PostCompact: { - summary: 'After conversation compaction', - description: - 'Input to command is JSON with compaction details and the summary.\nExit code 0 - stdout shown to user\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['manual', 'auto'], - }, - }, - SessionEnd: { - summary: 'When a session is ending', - description: - 'Input to command is JSON with session end reason.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'reason', - values: ['clear', 'logout', 'prompt_input_exit', 'other'], - }, - }, - PermissionRequest: { - summary: 'When a permission dialog is displayed', - description: - 'Input to command is JSON with tool_name, tool_input, and tool_use_id.\nOutput JSON with hookSpecificOutput containing decision to allow or deny.\nExit code 0 - use hook decision if provided\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'tool_name', - values: toolNames, - }, - }, - Setup: { - summary: 'Repo setup hooks for init and maintenance', - description: - 'Input to command is JSON with trigger (init or maintenance).\nExit code 0 - stdout shown to Claude\nBlocking errors are ignored\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'trigger', - values: ['init', 'maintenance'], - }, - }, - TeammateIdle: { - summary: 'When a teammate is about to go idle', - description: - 'Input to command is JSON with teammate_name and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to teammate and prevent idle (teammate continues working)\nOther exit codes - show stderr to user only', - }, - TaskCreated: { - summary: 'When a task is being created', - description: - 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task creation\nOther exit codes - show stderr to user only', - }, - TaskCompleted: { - summary: 'When a task is being marked as completed', - description: - 'Input to command is JSON with task_id, task_subject, task_description, teammate_name, and team_name.\nExit code 0 - stdout/stderr not shown\nExit code 2 - show stderr to model and prevent task completion\nOther exit codes - show stderr to user only', - }, - Elicitation: { - summary: 'When an MCP server requests user input (elicitation)', - description: - 'Input to command is JSON with mcp_server_name, message, and requested_schema.\nOutput JSON with hookSpecificOutput containing action (accept/decline/cancel) and optional content.\nExit code 0 - use hook response if provided\nExit code 2 - deny the elicitation\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'mcp_server_name', - values: [], - }, - }, - ElicitationResult: { - summary: 'After a user responds to an MCP elicitation', - description: - 'Input to command is JSON with mcp_server_name, action, content, mode, and elicitation_id.\nOutput JSON with hookSpecificOutput containing optional action and content to override the response.\nExit code 0 - use hook response if provided\nExit code 2 - block the response (action becomes decline)\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'mcp_server_name', - values: [], - }, - }, - ConfigChange: { - summary: 'When configuration files change during a session', - description: - 'Input to command is JSON with source (user_settings, project_settings, local_settings, policy_settings, skills) and file_path.\nExit code 0 - allow the change\nExit code 2 - block the change from being applied to the session\nOther exit codes - show stderr to user only', - matcherMetadata: { - fieldToMatch: 'source', - values: [ - 'user_settings', - 'project_settings', - 'local_settings', - 'policy_settings', - 'skills', - ], - }, - }, - InstructionsLoaded: { - summary: 'When an instruction file (CLAUDE.md or rule) is loaded', - description: - 'Input to command is JSON with file_path, memory_type (User, Project, Local, Managed), load_reason (session_start, nested_traversal, path_glob_match, include, compact), globs (optional 鈥?the paths: frontmatter patterns that matched), trigger_file_path (optional 鈥?the file Claude touched that caused the load), and parent_file_path (optional 鈥?the file that @-included this one).\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only\nThis hook is observability-only and does not support blocking.', - matcherMetadata: { - fieldToMatch: 'load_reason', - values: [ - 'session_start', - 'nested_traversal', - 'path_glob_match', - 'include', - 'compact', - ], - }, - }, - WorktreeCreate: { - summary: 'Create an isolated worktree for VCS-agnostic isolation', - description: - 'Input to command is JSON with name (suggested worktree slug).\nStdout should contain the absolute path to the created worktree directory.\nExit code 0 - worktree created successfully\nOther exit codes - worktree creation failed', - }, - WorktreeRemove: { - summary: 'Remove a previously created worktree', - description: - 'Input to command is JSON with worktree_path (absolute path to worktree).\nExit code 0 - worktree removed successfully\nOther exit codes - show stderr to user only', - }, - CwdChanged: { - summary: 'After the working directory changes', - description: - 'Input to command is JSON with old_cwd and new_cwd.\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to register with the FileChanged watcher.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - }, - FileChanged: { - summary: 'When a watched file changes', - description: - 'Input to command is JSON with file_path and event (change, add, unlink).\nCLAUDE_ENV_FILE is set 鈥?write bash exports there to apply env to subsequent BashTool commands.\nThe matcher field specifies filenames to watch in the current directory (e.g. ".envrc|.env").\nHook output can include hookSpecificOutput.watchPaths (array of absolute paths) to dynamically update the watch list.\nExit code 0 - command completes successfully\nOther exit codes - show stderr to user only', - }, - } - }, - toolNames => toolNames.slice().sort().join(','), -) - -// Group hooks by event and matcher -export function groupHooksByEventAndMatcher( - appState: AppState, - toolNames: string[], -): Record> { - const grouped: Record> = { - PreToolUse: {}, - PostToolUse: {}, - PostToolUseFailure: {}, - PermissionDenied: {}, - Notification: {}, - UserPromptSubmit: {}, - SessionStart: {}, - SessionEnd: {}, - Stop: {}, - StopFailure: {}, - SubagentStart: {}, - SubagentStop: {}, - PreCompact: {}, - PostCompact: {}, - PermissionRequest: {}, - Setup: {}, - TeammateIdle: {}, - TaskCreated: {}, - TaskCompleted: {}, - Elicitation: {}, - ElicitationResult: {}, - ConfigChange: {}, - WorktreeCreate: {}, - WorktreeRemove: {}, - InstructionsLoaded: {}, - CwdChanged: {}, - FileChanged: {}, - } - - const metadata = getHookEventMetadata(toolNames) - - // Include hooks from settings files - getAllHooks(appState).forEach(hook => { - const eventGroup = grouped[hook.event] - if (eventGroup) { - // For events without matchers, use empty string as key - const matcherKey = - metadata[hook.event].matcherMetadata !== undefined - ? hook.matcher || '' - : '' - if (!eventGroup[matcherKey]) { - eventGroup[matcherKey] = [] - } - eventGroup[matcherKey].push(hook) - } - }) - - // Include registered hooks (e.g., plugin hooks) - const registeredHooks = getRegisteredHooks() - if (registeredHooks) { - for (const [event, matchers] of Object.entries(registeredHooks)) { - const hookEvent = event as HookEvent - const eventGroup = grouped[hookEvent] - if (!eventGroup) continue - - for (const matcher of matchers) { - const matcherKey = matcher.matcher || '' - - // Only PluginHookMatcher has pluginRoot; HookCallbackMatcher (internal - // callbacks like attributionHooks, sessionFileAccessHooks) does not. - if ('pluginRoot' in matcher) { - eventGroup[matcherKey] ??= [] - for (const hook of matcher.hooks) { - eventGroup[matcherKey].push({ - event: hookEvent, - config: hook, - matcher: matcher.matcher, - source: 'pluginHook', - pluginName: matcher.pluginId, - }) - } - } else if (process.env.USER_TYPE === 'ant') { - eventGroup[matcherKey] ??= [] - for (const _hook of matcher.hooks) { - eventGroup[matcherKey].push({ - event: hookEvent, - config: { - type: 'command', - command: '[ANT-ONLY] Built-in Hook', - }, - matcher: matcher.matcher, - source: 'builtinHook', - }) - } - } - } - } - } - - return grouped -} - -// Get sorted matchers for a specific event -export function getSortedMatchersForEvent( - hooksByEventAndMatcher: Record< - HookEvent, - Record - >, - event: HookEvent, -): string[] { - const matchers = Object.keys(hooksByEventAndMatcher[event] || {}) - return sortMatchersByPriority(matchers, hooksByEventAndMatcher, event) -} - -// Get hooks for a specific event and matcher -export function getHooksForMatcher( - hooksByEventAndMatcher: Record< - HookEvent, - Record - >, - event: HookEvent, - matcher: string | null, -): IndividualHookConfig[] { - // For events without matchers, hooks are stored with empty string as key - // because the record keys must be strings. - const matcherKey = matcher ?? '' - return hooksByEventAndMatcher[event]?.[matcherKey] ?? [] -} - -// Get metadata for a specific event's matcher -export function getMatcherMetadata( - event: HookEvent, - toolNames: string[], -): MatcherMetadata | undefined { - return getHookEventMetadata(toolNames)[event].matcherMetadata -} diff --git a/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts b/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts deleted file mode 100644 index 49beebcc9e..0000000000 --- a/hook-dump/src_utils_hooks_hooksConfigSnapshot.ts +++ /dev/null @@ -1,136 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksConfigSnapshot.ts -LINES: 133 -========== -import { resetSdkInitState } from '../../bootstrap/state.js' -import { isRestrictedToPluginOnly } from '../settings/pluginOnlyPolicy.js' -// Import as module object so spyOn works in tests (direct imports bypass spies) -import * as settingsModule from '../settings/settings.js' -import { resetSettingsCache } from '../settings/settingsCache.js' -import type { HooksSettings } from '../settings/types.js' - -let initialHooksConfig: HooksSettings | null = null - -/** - * Get hooks from allowed sources. - * If allowManagedHooksOnly is set in policySettings, only managed hooks are returned. - * If disableAllHooks is set in policySettings, no hooks are returned. - * If disableAllHooks is set in non-managed settings, only managed hooks are returned - * (non-managed settings cannot disable managed hooks). - * Otherwise, returns merged hooks from all sources (backwards compatible). - */ -function getHooksFromAllowedSources(): HooksSettings { - const policySettings = settingsModule.getSettingsForSource('policySettings') - - // If managed settings disables all hooks, return empty - if (policySettings?.disableAllHooks === true) { - return {} - } - - // If allowManagedHooksOnly is set in managed settings, only use managed hooks - if (policySettings?.allowManagedHooksOnly === true) { - return policySettings.hooks ?? {} - } - - // strictPluginOnlyCustomization: block user/project/local settings hooks. - // Plugin hooks (registered channel, hooks.ts:1391) are NOT affected 鈥? - // they're assembled separately and the managedOnly skip there is keyed - // on shouldAllowManagedHooksOnly(), not on this policy. Agent frontmatter - // hooks are gated at REGISTRATION (runAgent.ts:~535) by agent source 鈥? - // plugin/built-in/policySettings agents register normally, user-sourced - // agents skip registration under ["hooks"]. A blanket execution-time - // block here would over-kill plugin agents' hooks. - if (isRestrictedToPluginOnly('hooks')) { - return policySettings?.hooks ?? {} - } - - const mergedSettings = settingsModule.getSettings_DEPRECATED() - - // If disableAllHooks is set in non-managed settings, only managed hooks still run - // (non-managed settings cannot override managed hooks) - if (mergedSettings.disableAllHooks === true) { - return policySettings?.hooks ?? {} - } - - // Otherwise, use all hooks (merged from all sources) - backwards compatible - return mergedSettings.hooks ?? {} -} - -/** - * Check if only managed hooks should run. - * This is true when: - * - policySettings has allowManagedHooksOnly: true, OR - * - disableAllHooks is set in non-managed settings (non-managed settings - * cannot disable managed hooks, so they effectively become managed-only) - */ -export function shouldAllowManagedHooksOnly(): boolean { - const policySettings = settingsModule.getSettingsForSource('policySettings') - if (policySettings?.allowManagedHooksOnly === true) { - return true - } - // If disableAllHooks is set but NOT from managed settings, - // treat as managed-only (non-managed hooks disabled, managed hooks still run) - if ( - settingsModule.getSettings_DEPRECATED().disableAllHooks === true && - policySettings?.disableAllHooks !== true - ) { - return true - } - return false -} - -/** - * Check if all hooks (including managed) should be disabled. - * This is only true when managed/policy settings has disableAllHooks: true. - * When disableAllHooks is set in non-managed settings, managed hooks still run. - */ -export function shouldDisableAllHooksIncludingManaged(): boolean { - return ( - settingsModule.getSettingsForSource('policySettings')?.disableAllHooks === - true - ) -} - -/** - * Capture a snapshot of the current hooks configuration - * This should be called once during application startup - * Respects the allowManagedHooksOnly setting - */ -export function captureHooksConfigSnapshot(): void { - initialHooksConfig = getHooksFromAllowedSources() -} - -/** - * Update the hooks configuration snapshot - * This should be called when hooks are modified through the settings - * Respects the allowManagedHooksOnly setting - */ -export function updateHooksConfigSnapshot(): void { - // Reset the session cache to ensure we read fresh settings from disk. - // Without this, the snapshot could use stale cached settings when the user - // edits settings.json externally and then runs /hooks - the session cache - // may not have been invalidated yet (e.g., if the file watcher's stability - // threshold hasn't elapsed). - resetSettingsCache() - initialHooksConfig = getHooksFromAllowedSources() -} - -/** - * Get the current hooks configuration from snapshot - * Falls back to settings if no snapshot exists - * @returns The hooks configuration - */ -export function getHooksConfigFromSnapshot(): HooksSettings | null { - if (initialHooksConfig === null) { - captureHooksConfigSnapshot() - } - return initialHooksConfig -} - -/** - * Reset the hooks configuration snapshot (useful for testing) - * Also resets SDK init state to prevent test pollution - */ -export function resetHooksConfigSnapshot(): void { - initialHooksConfig = null - resetSdkInitState() -} diff --git a/hook-dump/src_utils_hooks_hooksSettings.ts b/hook-dump/src_utils_hooks_hooksSettings.ts deleted file mode 100644 index 11ea49da2e..0000000000 --- a/hook-dump/src_utils_hooks_hooksSettings.ts +++ /dev/null @@ -1,274 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\hooksSettings.ts -LINES: 271 -========== -import { resolve } from 'path' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { getSessionId } from '../../bootstrap/state.js' -import type { AppState } from '../../state/AppState.js' -import type { EditableSettingSource } from '../settings/constants.js' -import { SOURCES } from '../settings/constants.js' -import { - getSettingsFilePathForSource, - getSettingsForSource, -} from '../settings/settings.js' -import type { HookCommand, HookMatcher } from '../settings/types.js' -import { DEFAULT_HOOK_SHELL } from '../shell/shellProvider.js' -import { getSessionHooks } from './sessionHooks.js' - -export type HookSource = - | EditableSettingSource - | 'policySettings' - | 'pluginHook' - | 'sessionHook' - | 'builtinHook' - -export interface IndividualHookConfig { - event: HookEvent - config: HookCommand - matcher?: string - source: HookSource - pluginName?: string -} - -/** - * Check if two hooks are equal (comparing only command/prompt content, not timeout) - */ -export function isHookEqual( - a: HookCommand | { type: 'function'; timeout?: number }, - b: HookCommand | { type: 'function'; timeout?: number }, -): boolean { - if (a.type !== b.type) return false - - // Use switch for exhaustive type checking - // Note: We only compare command/prompt content, not timeout - // `if` is part of identity: same command with different `if` conditions - // are distinct hooks (e.g., setup.sh if=Bash(git *) vs if=Bash(npm *)). - const sameIf = (x: { if?: string }, y: { if?: string }) => - (x.if ?? '') === (y.if ?? '') - switch (a.type) { - case 'command': - // shell is part of identity: same command string with different - // shells are distinct hooks. Default 'bash' so undefined === 'bash'. - return ( - b.type === 'command' && - a.command === b.command && - (a.shell ?? DEFAULT_HOOK_SHELL) === (b.shell ?? DEFAULT_HOOK_SHELL) && - sameIf(a, b) - ) - case 'prompt': - return b.type === 'prompt' && a.prompt === b.prompt && sameIf(a, b) - case 'agent': - return b.type === 'agent' && a.prompt === b.prompt && sameIf(a, b) - case 'http': - return b.type === 'http' && a.url === b.url && sameIf(a, b) - case 'function': - // Function hooks can't be compared (no stable identifier) - return false - } -} - -/** Get the display text for a hook */ -export function getHookDisplayText( - hook: HookCommand | { type: 'callback' | 'function'; statusMessage?: string }, -): string { - // Return custom status message if provided - if ('statusMessage' in hook && hook.statusMessage) { - return hook.statusMessage - } - - switch (hook.type) { - case 'command': - return hook.command - case 'prompt': - return hook.prompt - case 'agent': - return hook.prompt - case 'http': - return hook.url - case 'callback': - return 'callback' - case 'function': - return 'function' - } -} - -export function getAllHooks(appState: AppState): IndividualHookConfig[] { - const hooks: IndividualHookConfig[] = [] - - // Check if restricted to managed hooks only - const policySettings = getSettingsForSource('policySettings') - const restrictedToManagedOnly = policySettings?.allowManagedHooksOnly === true - - // If allowManagedHooksOnly is set, don't show any hooks in the UI - // (user/project/local are blocked, and managed hooks are intentionally hidden) - if (!restrictedToManagedOnly) { - // Get hooks from all editable sources - const sources = [ - 'userSettings', - 'projectSettings', - 'localSettings', - ] as EditableSettingSource[] - - // Track which settings files we've already processed to avoid duplicates - // (e.g., when running from home directory, userSettings and projectSettings - // both resolve to ~/.claude/settings.json) - const seenFiles = new Set() - - for (const source of sources) { - const filePath = getSettingsFilePathForSource(source) - if (filePath) { - const resolvedPath = resolve(filePath) - if (seenFiles.has(resolvedPath)) { - continue - } - seenFiles.add(resolvedPath) - } - - const sourceSettings = getSettingsForSource(source) - if (!sourceSettings?.hooks) { - continue - } - - for (const [event, matchers] of Object.entries(sourceSettings.hooks)) { - for (const matcher of matchers as HookMatcher[]) { - for (const hookCommand of matcher.hooks) { - hooks.push({ - event: event as HookEvent, - config: hookCommand, - matcher: matcher.matcher, - source, - }) - } - } - } - } - } - - // Get session hooks - const sessionId = getSessionId() - const sessionHooks = getSessionHooks(appState, sessionId) - for (const [event, matchers] of sessionHooks.entries()) { - for (const matcher of matchers) { - for (const hookCommand of matcher.hooks) { - hooks.push({ - event, - config: hookCommand, - matcher: matcher.matcher, - source: 'sessionHook', - }) - } - } - } - - return hooks -} - -export function getHooksForEvent( - appState: AppState, - event: HookEvent, -): IndividualHookConfig[] { - return getAllHooks(appState).filter(hook => hook.event === event) -} - -export function hookSourceDescriptionDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User settings (~/.claude/settings.json)' - case 'projectSettings': - return 'Project settings (.claude/settings.json)' - case 'localSettings': - return 'Local settings (.claude/settings.local.json)' - case 'pluginHook': - // TODO: Get the actual plugin hook file paths instead of using glob pattern - // We should capture the specific plugin paths during hook registration and display them here - // e.g., "Plugin hooks (~/.claude/plugins/repos/source/example-plugin/example-plugin/hooks/hooks.json)" - return 'Plugin hooks (~/.claude/plugins/*/hooks/hooks.json)' - case 'sessionHook': - return 'Session hooks (in-memory, temporary)' - case 'builtinHook': - return 'Built-in hooks (registered internally by Claude Code)' - default: - return source as string - } -} - -export function hookSourceHeaderDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User Settings' - case 'projectSettings': - return 'Project Settings' - case 'localSettings': - return 'Local Settings' - case 'pluginHook': - return 'Plugin Hooks' - case 'sessionHook': - return 'Session Hooks' - case 'builtinHook': - return 'Built-in Hooks' - default: - return source as string - } -} - -export function hookSourceInlineDisplayString(source: HookSource): string { - switch (source) { - case 'userSettings': - return 'User' - case 'projectSettings': - return 'Project' - case 'localSettings': - return 'Local' - case 'pluginHook': - return 'Plugin' - case 'sessionHook': - return 'Session' - case 'builtinHook': - return 'Built-in' - default: - return source as string - } -} - -export function sortMatchersByPriority( - matchers: string[], - hooksByEventAndMatcher: Record< - string, - Record - >, - selectedEvent: HookEvent, -): string[] { - // Create a priority map based on SOURCES order (lower index = higher priority) - const sourcePriority = SOURCES.reduce( - (acc, source, index) => { - acc[source] = index - return acc - }, - {} as Record, - ) - - return [...matchers].sort((a, b) => { - const aHooks = hooksByEventAndMatcher[selectedEvent]?.[a] || [] - const bHooks = hooksByEventAndMatcher[selectedEvent]?.[b] || [] - - const aSources = Array.from(new Set(aHooks.map(h => h.source))) - const bSources = Array.from(new Set(bHooks.map(h => h.source))) - - // Sort by highest priority source first (lowest priority number) - // Plugin hooks get lowest priority (highest number) - const getSourcePriority = (source: HookSource) => - source === 'pluginHook' || source === 'builtinHook' - ? 999 - : sourcePriority[source as EditableSettingSource] - - const aHighestPriority = Math.min(...aSources.map(getSourcePriority)) - const bHighestPriority = Math.min(...bSources.map(getSourcePriority)) - - if (aHighestPriority !== bHighestPriority) { - return aHighestPriority - bHighestPriority - } - - // If same priority, sort by matcher name - return a.localeCompare(b) - }) -} diff --git a/hook-dump/src_utils_hooks_postSamplingHooks.ts b/hook-dump/src_utils_hooks_postSamplingHooks.ts deleted file mode 100644 index b2541a3f5c..0000000000 --- a/hook-dump/src_utils_hooks_postSamplingHooks.ts +++ /dev/null @@ -1,73 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\postSamplingHooks.ts -LINES: 70 -========== -import type { QuerySource } from '../../constants/querySource.js' -import type { ToolUseContext } from '../../Tool.js' -import type { Message } from '../../types/message.js' -import { toError } from '../errors.js' -import { logError } from '../log.js' -import type { SystemPrompt } from '../systemPromptType.js' - -// Post-sampling hook - not exposed in settings.json config (yet), only used programmatically - -// Generic context for REPL hooks (both post-sampling and stop hooks) -export type REPLHookContext = { - messages: Message[] // Full message history including assistant responses - systemPrompt: SystemPrompt - userContext: { [k: string]: string } - systemContext: { [k: string]: string } - toolUseContext: ToolUseContext - querySource?: QuerySource -} - -export type PostSamplingHook = ( - context: REPLHookContext, -) => Promise | void - -// Internal registry for post-sampling hooks -const postSamplingHooks: PostSamplingHook[] = [] - -/** - * Register a post-sampling hook that will be called after model sampling completes - * This is an internal API not exposed through settings - */ -export function registerPostSamplingHook(hook: PostSamplingHook): void { - postSamplingHooks.push(hook) -} - -/** - * Clear all registered post-sampling hooks (for testing) - */ -export function clearPostSamplingHooks(): void { - postSamplingHooks.length = 0 -} - -/** - * Execute all registered post-sampling hooks - */ -export async function executePostSamplingHooks( - messages: Message[], - systemPrompt: SystemPrompt, - userContext: { [k: string]: string }, - systemContext: { [k: string]: string }, - toolUseContext: ToolUseContext, - querySource?: QuerySource, -): Promise { - const context: REPLHookContext = { - messages, - systemPrompt, - userContext, - systemContext, - toolUseContext, - querySource, - } - - for (const hook of postSamplingHooks) { - try { - await hook(context) - } catch (error) { - // Log but don't fail on hook errors - logError(toError(error)) - } - } -} diff --git a/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts b/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts deleted file mode 100644 index d9744dc28a..0000000000 --- a/hook-dump/src_utils_hooks_registerFrontmatterHooks.ts +++ /dev/null @@ -1,70 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerFrontmatterHooks.ts -LINES: 67 -========== -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import { logForDebugging } from '../debug.js' -import type { HooksSettings } from '../settings/types.js' -import { addSessionHook } from './sessionHooks.js' - -/** - * Register hooks from frontmatter (agent or skill) into session-scoped hooks. - * These hooks will be active for the duration of the session/agent and cleaned up - * when the session/agent ends. - * - * @param setAppState Function to update app state - * @param sessionId Session ID to scope the hooks (agent ID for agents, session ID for skills) - * @param hooks The hooks settings from frontmatter - * @param sourceName Human-readable source name for logging (e.g., "agent 'my-agent'") - * @param isAgent If true, converts Stop hooks to SubagentStop (since subagents trigger SubagentStop, not Stop) - */ -export function registerFrontmatterHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - hooks: HooksSettings, - sourceName: string, - isAgent: boolean = false, -): void { - if (!hooks || Object.keys(hooks).length === 0) { - return - } - - let hookCount = 0 - - for (const event of HOOK_EVENTS) { - const matchers = hooks[event] - if (!matchers || matchers.length === 0) { - continue - } - - // For agents, convert Stop hooks to SubagentStop since that's what fires when an agent completes - // (executeStopHooks uses SubagentStop when called with an agentId) - let targetEvent: HookEvent = event - if (isAgent && event === 'Stop') { - targetEvent = 'SubagentStop' - logForDebugging( - `Converting Stop hook to SubagentStop for ${sourceName} (subagents trigger SubagentStop)`, - ) - } - - for (const matcherConfig of matchers) { - const matcher = matcherConfig.matcher ?? '' - const hooksArray = matcherConfig.hooks - - if (!hooksArray || hooksArray.length === 0) { - continue - } - - for (const hook of hooksArray) { - addSessionHook(setAppState, sessionId, targetEvent, matcher, hook) - hookCount++ - } - } - } - - if (hookCount > 0) { - logForDebugging( - `Registered ${hookCount} frontmatter hook(s) from ${sourceName} for session ${sessionId}`, - ) - } -} diff --git a/hook-dump/src_utils_hooks_registerSkillHooks.ts b/hook-dump/src_utils_hooks_registerSkillHooks.ts deleted file mode 100644 index bd7abb360b..0000000000 --- a/hook-dump/src_utils_hooks_registerSkillHooks.ts +++ /dev/null @@ -1,67 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\registerSkillHooks.ts -LINES: 64 -========== -import { HOOK_EVENTS } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import { logForDebugging } from '../debug.js' -import type { HooksSettings } from '../settings/types.js' -import { addSessionHook, removeSessionHook } from './sessionHooks.js' - -/** - * Registers hooks from a skill's frontmatter as session hooks. - * - * Hooks are registered as session-scoped hooks that persist for the duration - * of the session. If a hook has `once: true`, it will be automatically removed - * after its first successful execution. - * - * @param setAppState - Function to update the app state - * @param sessionId - The current session ID - * @param hooks - The hooks settings from the skill's frontmatter - * @param skillName - The name of the skill (for logging) - * @param skillRoot - The base directory of the skill (for CLAUDE_PLUGIN_ROOT env var) - */ -export function registerSkillHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - hooks: HooksSettings, - skillName: string, - skillRoot?: string, -): void { - let registeredCount = 0 - - for (const eventName of HOOK_EVENTS) { - const matchers = hooks[eventName] - if (!matchers) continue - - for (const matcher of matchers) { - for (const hook of matcher.hooks) { - // For once: true hooks, use onHookSuccess callback to remove after execution - const onHookSuccess = hook.once - ? () => { - logForDebugging( - `Removing one-shot hook for event ${eventName} in skill '${skillName}'`, - ) - removeSessionHook(setAppState, sessionId, eventName, hook) - } - : undefined - - addSessionHook( - setAppState, - sessionId, - eventName, - matcher.matcher || '', - hook, - onHookSuccess, - skillRoot, - ) - registeredCount++ - } - } - } - - if (registeredCount > 0) { - logForDebugging( - `Registered ${registeredCount} hooks from skill '${skillName}'`, - ) - } -} diff --git a/hook-dump/src_utils_hooks_sessionHooks.ts b/hook-dump/src_utils_hooks_sessionHooks.ts deleted file mode 100644 index 7e41ac1eb2..0000000000 --- a/hook-dump/src_utils_hooks_sessionHooks.ts +++ /dev/null @@ -1,450 +0,0 @@ -FILE: E:\Yuanban\cc-haha-src\src\utils\hooks\sessionHooks.ts -LINES: 447 -========== -import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { AppState } from 'src/state/AppState.js' -import type { Message } from 'src/types/message.js' -import { logForDebugging } from '../debug.js' -import type { AggregatedHookResult } from '../hooks.js' -import type { HookCommand } from '../settings/types.js' -import { isHookEqual } from './hooksSettings.js' - -type OnHookSuccess = ( - hook: HookCommand | FunctionHook, - result: AggregatedHookResult, -) => void - -/** Function hook callback - returns true if check passes, false to block */ -export type FunctionHookCallback = ( - messages: Message[], - signal?: AbortSignal, -) => boolean | Promise - -/** - * Function hook type with callback embedded. - * Session-scoped only, cannot be persisted to settings.json. - */ -export type FunctionHook = { - type: 'function' - id?: string // Optional unique ID for removal - timeout?: number - callback: FunctionHookCallback - errorMessage: string - statusMessage?: string -} - -type SessionHookMatcher = { - matcher: string - skillRoot?: string - hooks: Array<{ - hook: HookCommand | FunctionHook - onHookSuccess?: OnHookSuccess - }> -} - -export type SessionStore = { - hooks: { - [event in HookEvent]?: SessionHookMatcher[] - } -} - -/** - * Map (not Record) so .set/.delete don't change the container's identity. - * Mutator functions mutate the Map and return prev unchanged, letting - * store.ts's Object.is(next, prev) check short-circuit and skip listener - * notification. Session hooks are ephemeral per-agent runtime callbacks, - * never reactively read (only getAppState() snapshots in the query loop). - * Same pattern as agentControllers on LocalWorkflowTaskState. - * - * This matters under high-concurrency workflows: parallel() with N - * schema-mode agents fires N addFunctionHook calls in one synchronous - * tick. With a Record + spread, each call cost O(N) to copy the growing - * map (O(N虏) total) plus fired all ~30 store listeners. With Map: .set() - * is O(1), return prev means zero listener fires. - */ -export type SessionHooksState = Map - -/** - * Add a command or prompt hook to the session. - * Session hooks are temporary, in-memory only, and cleared when session ends. - */ -export function addSessionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand, - onHookSuccess?: OnHookSuccess, - skillRoot?: string, -): void { - addHookToSession( - setAppState, - sessionId, - event, - matcher, - hook, - onHookSuccess, - skillRoot, - ) -} - -/** - * Add a function hook to the session. - * Function hooks execute TypeScript callbacks in-memory for validation. - * @returns The hook ID (for removal) - */ -export function addFunctionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - callback: FunctionHookCallback, - errorMessage: string, - options?: { - timeout?: number - id?: string - }, -): string { - const id = options?.id || `function-hook-${Date.now()}-${Math.random()}` - const hook: FunctionHook = { - type: 'function', - id, - timeout: options?.timeout || 5000, - callback, - errorMessage, - } - addHookToSession(setAppState, sessionId, event, matcher, hook) - return id -} - -/** - * Remove a function hook by ID from the session. - */ -export function removeFunctionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - hookId: string, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) - if (!store) { - return prev - } - - const eventMatchers = store.hooks[event] || [] - - // Remove the hook with matching ID from all matchers - const updatedMatchers = eventMatchers - .map(matcher => { - const updatedHooks = matcher.hooks.filter(h => { - if (h.hook.type !== 'function') return true - return h.hook.id !== hookId - }) - - return updatedHooks.length > 0 - ? { ...matcher, hooks: updatedHooks } - : null - }) - .filter((m): m is SessionHookMatcher => m !== null) - - const newHooks = - updatedMatchers.length > 0 - ? { ...store.hooks, [event]: updatedMatchers } - : Object.fromEntries( - Object.entries(store.hooks).filter(([e]) => e !== event), - ) - - prev.sessionHooks.set(sessionId, { hooks: newHooks }) - return prev - }) - - logForDebugging( - `Removed function hook ${hookId} for event ${event} in session ${sessionId}`, - ) -} - -/** - * Internal helper to add a hook to session state - */ -function addHookToSession( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand | FunctionHook, - onHookSuccess?: OnHookSuccess, - skillRoot?: string, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) ?? { hooks: {} } - const eventMatchers = store.hooks[event] || [] - - // Find existing matcher or create new one - const existingMatcherIndex = eventMatchers.findIndex( - m => m.matcher === matcher && m.skillRoot === skillRoot, - ) - - let updatedMatchers: SessionHookMatcher[] - if (existingMatcherIndex >= 0) { - // Add to existing matcher - updatedMatchers = [...eventMatchers] - const existingMatcher = updatedMatchers[existingMatcherIndex]! - updatedMatchers[existingMatcherIndex] = { - matcher: existingMatcher.matcher, - skillRoot: existingMatcher.skillRoot, - hooks: [...existingMatcher.hooks, { hook, onHookSuccess }], - } - } else { - // Create new matcher - updatedMatchers = [ - ...eventMatchers, - { - matcher, - skillRoot, - hooks: [{ hook, onHookSuccess }], - }, - ] - } - - const newHooks = { ...store.hooks, [event]: updatedMatchers } - - prev.sessionHooks.set(sessionId, { hooks: newHooks }) - return prev - }) - - logForDebugging( - `Added session hook for event ${event} in session ${sessionId}`, - ) -} - -/** - * Remove a specific hook from the session - * @param setAppState The function to update the app state - * @param sessionId The session ID - * @param event The hook event - * @param hook The hook command to remove - */ -export function removeSessionHook( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, - event: HookEvent, - hook: HookCommand, -): void { - setAppState(prev => { - const store = prev.sessionHooks.get(sessionId) - if (!store) { - return prev - } - - const eventMatchers = store.hooks[event] || [] - - // Remove the hook from all matchers - const updatedMatchers = eventMatchers - .map(matcher => { - const updatedHooks = matcher.hooks.filter( - h => !isHookEqual(h.hook, hook), - ) - - return updatedHooks.length > 0 - ? { ...matcher, hooks: updatedHooks } - : null - }) - .filter((m): m is SessionHookMatcher => m !== null) - - const newHooks = - updatedMatchers.length > 0 - ? { ...store.hooks, [event]: updatedMatchers } - : { ...store.hooks } - - if (updatedMatchers.length === 0) { - delete newHooks[event] - } - - prev.sessionHooks.set(sessionId, { ...store, hooks: newHooks }) - return prev - }) - - logForDebugging( - `Removed session hook for event ${event} in session ${sessionId}`, - ) -} - -// Extended hook matcher that includes optional skillRoot for skill-scoped hooks -export type SessionDerivedHookMatcher = { - matcher: string - hooks: HookCommand[] - skillRoot?: string -} - -/** - * Convert session hook matchers to regular hook matchers - * @param sessionMatchers The session hook matchers to convert - * @returns Regular hook matchers (with optional skillRoot preserved) - */ -function convertToHookMatchers( - sessionMatchers: SessionHookMatcher[], -): SessionDerivedHookMatcher[] { - return sessionMatchers.map(sm => ({ - matcher: sm.matcher, - skillRoot: sm.skillRoot, - // Filter out function hooks - they can't be persisted to HookMatcher format - hooks: sm.hooks - .map(h => h.hook) - .filter((h): h is HookCommand => h.type !== 'function'), - })) -} - -/** - * Get all session hooks for a specific event (excluding function hooks) - * @param appState The app state - * @param sessionId The session ID - * @param event Optional event to filter by - * @returns Hook matchers for the event, or all hooks if no event specified - */ -export function getSessionHooks( - appState: AppState, - sessionId: string, - event?: HookEvent, -): Map { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return new Map() - } - - const result = new Map() - - if (event) { - const sessionMatchers = store.hooks[event] - if (sessionMatchers) { - result.set(event, convertToHookMatchers(sessionMatchers)) - } - return result - } - - for (const evt of HOOK_EVENTS) { - const sessionMatchers = store.hooks[evt] - if (sessionMatchers) { - result.set(evt, convertToHookMatchers(sessionMatchers)) - } - } - - return result -} - -type FunctionHookMatcher = { - matcher: string - hooks: FunctionHook[] -} - -/** - * Get all session function hooks for a specific event - * Function hooks are kept separate because they can't be persisted to HookMatcher format. - * @param appState The app state - * @param sessionId The session ID - * @param event Optional event to filter by - * @returns Function hook matchers for the event - */ -export function getSessionFunctionHooks( - appState: AppState, - sessionId: string, - event?: HookEvent, -): Map { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return new Map() - } - - const result = new Map() - - const extractFunctionHooks = ( - sessionMatchers: SessionHookMatcher[], - ): FunctionHookMatcher[] => { - return sessionMatchers - .map(sm => ({ - matcher: sm.matcher, - hooks: sm.hooks - .map(h => h.hook) - .filter((h): h is FunctionHook => h.type === 'function'), - })) - .filter(m => m.hooks.length > 0) - } - - if (event) { - const sessionMatchers = store.hooks[event] - if (sessionMatchers) { - const functionMatchers = extractFunctionHooks(sessionMatchers) - if (functionMatchers.length > 0) { - result.set(event, functionMatchers) - } - } - return result - } - - for (const evt of HOOK_EVENTS) { - const sessionMatchers = store.hooks[evt] - if (sessionMatchers) { - const functionMatchers = extractFunctionHooks(sessionMatchers) - if (functionMatchers.length > 0) { - result.set(evt, functionMatchers) - } - } - } - - return result -} - -/** - * Get the full hook entry (including callbacks) for a specific session hook - */ -export function getSessionHookCallback( - appState: AppState, - sessionId: string, - event: HookEvent, - matcher: string, - hook: HookCommand | FunctionHook, -): - | { - hook: HookCommand | FunctionHook - onHookSuccess?: OnHookSuccess - } - | undefined { - const store = appState.sessionHooks.get(sessionId) - if (!store) { - return undefined - } - - const eventMatchers = store.hooks[event] - if (!eventMatchers) { - return undefined - } - - // Find the hook in the matchers - for (const matcherEntry of eventMatchers) { - if (matcherEntry.matcher === matcher || matcher === '') { - const hookEntry = matcherEntry.hooks.find(h => isHookEqual(h.hook, hook)) - if (hookEntry) { - return hookEntry - } - } - } - - return undefined -} - -/** - * Clear all session hooks for a specific session - * @param setAppState The function to update the app state - * @param sessionId The session ID - */ -export function clearSessionHooks( - setAppState: (updater: (prev: AppState) => AppState) => void, - sessionId: string, -): void { - setAppState(prev => { - prev.sessionHooks.delete(sessionId) - return prev - }) - - logForDebugging(`Cleared all session hooks for session ${sessionId}`) -} diff --git a/pr-body-original.md b/pr-body-original.md deleted file mode 100644 index daf15bfb88..0000000000 --- a/pr-body-original.md +++ /dev/null @@ -1,241 +0,0 @@ - -## 免责声明 - -- **Vibe Coding**: 本 PR 全部代码由 AI 生成,提交者无编程背景,仅提供设计思路、架构方向与代码示例参考。 -- **开发环境**: 全程使用 BitFun + DeepSeek V4 Pro 对话开发,通过 BitFun DeepReview 进行代码审核。 -- **实验性质**: 本 PR 是探索性框架示例,旨在验证 BitFun 原生基础设施是否支持复杂长任务的 Multi-Agent Workflow 编排能力。并非生产级实现,希望可为官方功能更新提供参考。 -- **测试程度**: AI 辅助完成,已通过 `cargo check --workspace` + `pnpm run type-check:web` + Team 模式端到端功能验证。未经过完整单元测试覆盖。 - ---- - -## 核心设计思路 - -**不积跬步,无以至千里;千里之堤,溃于蚁穴。** 长任务实现的本质不是"一个 Agent 扛到底",而是把每一步都做对——每一步过审查、每一步纠偏、每一步确定性输出。 - -**原子任务分解**: 任何复杂任务都可分解为不可再分的原子步骤。分解粒度决定并行度——可并行的并行(提效),有依赖的串行(保流程)。 - -**三蜂 Loop 循环**: 每个原子任务由提示蜂+执行蜂+审查蜂三个 Agent 通过 SessionMessage 自循环。Loop 确保执行上下文最大化节省 token,不通过 Gate 绝不输出到下一步。审查蜂不是"最后看一眼",是每一步都盯——偷懒、幻觉、死循环、跳过验证——实时拦截+纠正注入。 - -**事实、效率、结果**: 三个不可妥协的维度。事实 = 每一步基于证据,不做假设;效率 = 最大化并行,最小化空转;结果 = Gate 通过才是输出,不通过就是回退。 - -**只调度不执行**: 军团长分解任务、拓扑排序、并行派发、Gate 裁决。不亲手改一行代码。 - -**Mesh 网状通信**: 军团成员之间 SessionMessage 直连(不经军团长),形成真正的对等网络。 - -**现成积木最大化复用**: DeepReview 审查团队已是完整三蜂结构。Claw 已有 session 工具。不做新引擎——泛化现有结构即可支持任意任务编排。不引入外部依赖。 - -**每一步都是正确的**: 行为守卫生效(Hook Abort)+ 审查蜂实时行为审计 + Gate Loop 层层把关 → 长任务才敢放心交出去。 - ---- - -## 蜂群架构:多Agent军团编排 + 审查Hook + DAG画布 - -### 一、核心架构思想 - -**公司(分解派发) + 军事(容错重组) + 航天(追溯零错)** 三域合一,通过八个固定Agent角色实现任意复杂度的任务编排。 - -#### 八个固定角色(积木) -| 角色 | 二元决策树 | 职责 | -|------|-----------|------| -| 指挥官 Commander | 树1-3 P→C→D | 感知全局→认知根因→决策方向,只指挥不执行 | -| 秘书 B01 Secretary | L3库检索 | 按任务领域检索提示词/技能/框架/规则,输出注入包 | -| 产品经理 ProductManager | R-ID需求定义 | 方向→结构化需求文档,分配追溯ID+验收标准 | -| 规划师 Planner | 树4-6 P→D→C | 需求→原子步分解,判依赖→串行/并行派发 | -| 执行者 Executor | 树7-10 O→O→D→A | 观察→思考→决策→执行,收到退回自动切Debug模式 | -| 审查者 Reviewer | 三节点审查 | ①功能审查(QA) ②安全审查(OWASP/CVE) ③Debug(诊断→退回) | -| 验收者 Acceptor | R-ID逐项闭合 | 对照需求文档按R-ID验证,全部闭合才交付 | -| 优化者 Optimizer | 复盘+归档+知识库 | 复盘进化→更新模板,新知识→写入L3库,产物→命名归档 | - -#### 六条原则 -> 串行保流程,并行提效率,循环促执行,节点控准确,原子降难度,嵌套解万物 - -#### 10棵二元决策树 -每棵只有两条路:通过→下一棵,不通过→修正→重检→直到通过。任何节点可展开为完整子链(自由嵌套)。 - -#### 与ruflo的本质差异 -角色固定(八个通用骨架),提示词灵活。同一executor注入量化提示词→量化执行者,注入前端提示词→前端执行者。B01管理领域提示词库。 - ---- - -### 二、LegionControl 军团编排 - -**动态DAG拓扑排序执行引擎。** 读JSON模板→拓扑排序→分层创建session→注入上下文→串行/并行派发。 - -#### 核心实现 -- `LegionControl` 工具:load模板 / list / status,支持自定义agent类型 -- 拓扑排序算法:条件边跳过(空condition字段不参与环检测),分层并发创建 -- `SessionControl`:Collapsed→Expanded,支持动态创建/列出/关闭会话 -- `SessionMessage`:跨会话消息派发,传workspace路径和agent类型 -- `SessionHistory`:导出会话转录,支持turn选择器(单轮/范围/最后N轮) - -#### 预设模板 -- `bee-colony-standard`:8节点全链(cmd→sec→pm→plan→exec→review→accept→opt) -- `bee-colony-quick`:4节点精简链(cmd→exec→review→accept) -- `bee-colony-parallel`:12节点3并行执行者+各自审查 -- `bee-colony-single`:单执行者模式 -- `bee-colony-multi-legion`:14节点多军团接力 - ---- - -### 三、cc-haha 模式审查Hook - -**每轮对话结束触发LLM审查(非自审,非Agent会话),审查结果注入下一轮上下文。** - -``` -每轮(有工具调用) - │ - ├─ stop hook: 检查 REVIEW_BUFFER → 注入上轮审查结果 - │ ABORT:* → HookResult::Abort(硬拦截) - │ CTX:* → [书记官] 上下文恢复 - │ SKILL:* → [提示蜂] 技能推荐 - │ WARN:* → [审查员] 警告 - │ - └─ std::thread → primary model → LLM审查本轮 - → 结果 push REVIEW_BUFFER → 下轮stop hook取出 -``` - -#### 审查员三合一职责 -1. **书记官(上下文守护)**:检测上下文压缩→提取压缩前关键决策/进度/用户纠正→CTX注入 -2. **纪律委员(行为审查)**:Read-before-Edit、LionHeart保护、策略死循环、PS+中文+JSON、全部工具失败、改后未验证→ABORT/WARN -3. **提示蜂(技能推荐)**:匹配Agent行为到可用BitFun技能→SKILL推荐 - -#### 设计要点 -- cc-haha模式:stop→外部进程→结果注入,不创建Agent会话,不fork上下文 -- 使用primary模型(与主Agent同款),非独立fast模型 -- 同步检查全部移除,AI全权判断 -- 平时沉默(PASS过滤),只在违规/上下文退化时出声 - ---- - -### 四、STALE_TRACKER 修复 - -新增`last_target`字段区分"同工具不同文件"和"同工具同文件"。 -- Write commander.md → Write planner.md → Write executor.md(不同target)→计数器重置 -- 修复前:Write×4连续触发误判Abort -- 修复后:target变化时计数器重置 - ---- - -### 五、蜂群DAG画布MiniApp - -#### 内置MiniApp `bee-colony-dag` -- 8节点纵向DAG(cmd→sec→pm→plan→exec→review→accept→opt) -- 4色状态:idle=灰、running=蓝(脉冲动画)、done=绿、failed=红 -- Gate节点(review/accept)红色虚线边框 -- 500ms轮询`app.storage('bee-colony-state')`实时更新 - -#### BeeColonyMonitor浮动面板 -- GitBranch按钮固定在Agent场景右下角 -- 点击展开MiniAppRunner加载蜂群DAG -- 支持最大化/还原 - -#### 状态推送工具 -`bee_colony_state_push.py`:set/reset/批量JSON三种模式,直写storage.json - ---- - -### 六、Agent注册 - -| Agent | 类型 | 文件 | -|-------|------|------| -| commander | Mode | `%APPDATA%/bitfun/agents/commander.md` | -| secretary (B01) | Subagent | `%APPDATA%/bitfun/agents/secretary.md` | -| product-manager | Subagent | `%APPDATA%/bitfun/agents/product-manager.md` | -| planner | Subagent | `%APPDATA%/bitfun/agents/planner.md` | -| executor | Subagent | `%APPDATA%/bitfun/agents/executor.md` | -| reviewer | Subagent | `%APPDATA%/bitfun/agents/reviewer.md` | -| acceptor | Subagent | `%APPDATA%/bitfun/agents/acceptor.md` | -| optimizer | Subagent | `%APPDATA%/bitfun/agents/optimizer.md` | -| bee-reviewer | Subagent | `%APPDATA%/bitfun/agents/bee-reviewer.md` | -| b01-context-agent | Subagent | `%APPDATA%/bitfun/agents/b01-context-agent.md` | -| c01-audit-agent | Subagent | `%APPDATA%/bitfun/agents/c01-audit-agent.md` | - ---- - -### 七、关键文件变更 - -| 文件 | 变更 | -|------|------| -| `legion_control_tool.rs` | 新增,拓扑排序+分层创建+条件边 | -| `post_call_hooks.rs` (core) | cc-haha审查:REVIEW_BUFFER+stop hook注入+三职合一批量处理 | -| `post_call_hooks.rs` (agent-runtime) | ToolCallSummary扩展input_preview字段 | -| `execution_engine.rs` | 每轮spawn审查LLM调用+primary模型 | -| `session_control_tool.rs` | Collapsed→Expanded曝光 | -| `session_message_tool.rs` | Collapsed→Expanded曝光 | -| `session_history_tool.rs` | Collapsed→Expanded曝光 | -| `builtin.rs` | bee-colony-dag内置注册 | -| `BeeColonyMonitor.tsx/.scss` | 新增浮动审查面板 | -| `commander.md` | 嵌入任务启动协议(B01优先) | -| `SKILL.md` v10.4 | 上下文恢复协议+三域架构+八角色完整定义 | -| `AgentsScene.tsx` | 解决main合并冲突,两边import都保留 | - ---- - -## Summary - -Agentic Dynamic Workflows: 泛化 BitFun 原生会话工具,实现智能体军团编排——Agent 通过 SessionControl 创建子 Agent 会话节点,SessionMessage 发送任务并自动接收回复,SessionHistory 审查战报,Goal 追踪进度。不引入新引擎,全用原生基础设施。 - -Fixes #N/A(新功能) - -## Type and Areas - -Type: **feat** - -Areas: **Rust core, desktop/Tauri, web UI, ACP interface** - -## Motivation / Impact - -**问题**: Team 模式的 SessionControl/SessionMessage/SessionHistory 三个工具处于 Collapsed 状态,Agent 看不到。session 工具的 agent_type 硬编码,ACP 外部 Agent 无法被编排。DeepReview 审查团队被限制为代码审查专用。 - -**改动**: -- 3 个 session 工具 Collapsed→Expanded,Team 模式直接可用 -- agent_type 从硬编码枚举改为 AgentRegistry 动态获取(含 ACP) -- ACP Agent 注册为 Mode,出现在智能体选择器中 -- LegionControl 工具:读军团模板 JSON,拓扑排序,一键创建多个 Agent 会话 -- Hook 框架加 Abort 能力 + BehaviorGuard 行为检测 -- team_mode.md 重写:从 gstack skills(316行)→ 军团指挥系统(108行)+ Gate Loop Protocol - -**影响**: Team/Claw/agentic/Plan/Debug/Multitask 模式现在全部能创建和互发消息。ACP 外部 Agent 可被编排。审查蜂可见性保持不变。 - -## Verification - -```bash -# Rust -cargo check --workspace # 零 error,零 warning - -# TypeScript -pnpm run type-check:web # 零 error - -# E2E 功能验证(Team 模式) -SessionControl(action:"list") ✅ 列出 11 个会话 -SessionControl(action:"create") ✅ 创建 agentic 节点 -SessionMessage(session_id, message) ✅ Plan/agentic/ACP 通信闭环 -SessionHistory(session_id) ✅ 战报导出 -LegionControl(action:"list"/"load") ✅ 列表+拓扑创建 -Goal get/create/update/complete ✅ 追踪 -ACP agent_type 动态注册 ✅ 13 个 Agent 入注册表 -LegionControl 条件边跳过 ✅ fail 边不参与循环检测 -Hook Abort 框架编译 ✅ 无 error -``` - -## Reviewer Notes - -**架构边界**: 所有改动严格遵循 BitFun 六层架构: -- contracts: 未改 -- execution: HookResult::Abort + BehaviorGuard(post_call_hooks.rs) -- services: 未改 -- adapters/ACP: AcpAgent 注册(manager.rs) -- assembly/core: 工具曝光 + 动态 agent_type + LegionControl + prompt 重写 -- interfaces: ACP agent 入前端核心区 - -**不引入新引擎**: 全用 SessionControl/SessionMessage/SessionHistory/Goal/Task 原生工具。 - -**不破坏现有功能**: DeepReview 审查流程不变,审查蜂可见性保持 Hidden/restricted 原始状态。 - -**AI 辅助**: 本 PR 由 AI 辅助完成,已通过 cargo check --workspace + type-check:web + 完整 E2E 功能验证。 - -## Checklist - -- [x] This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts. -- [x] Relevant verification is recorded above. -- [x] User-facing strings, docs, and locales are updated where applicable. \ No newline at end of file diff --git a/read-hooks.ps1 b/read-hooks.ps1 deleted file mode 100644 index 73e8c9dabe..0000000000 --- a/read-hooks.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -$ErrorActionPreference = 'Continue' -$outDir = "E:\Yuanban\BitFun-src\hook-dump" -New-Item -ItemType Directory -Force -Path $outDir | Out-Null - -$files = @( - "src\types\hooks.ts", - "src\utils\hooks.ts", - "src\schemas\hooks.ts", - "src\utils\hooks\hookEvents.ts", - "src\utils\hooks\hookHelpers.ts", - "src\utils\hooks\hooksConfigManager.ts", - "src\utils\hooks\sessionHooks.ts", - "src\utils\hooks\execPromptHook.ts", - "src\utils\hooks\execAgentHook.ts", - "src\utils\hooks\execHttpHook.ts", - "src\utils\hooks\AsyncHookRegistry.ts", - "src\utils\hooks\postSamplingHooks.ts", - "src\utils\hooks\hooksSettings.ts", - "src\utils\hooks\hooksConfigSnapshot.ts", - "src\utils\hooks\registerFrontmatterHooks.ts", - "src\utils\hooks\registerSkillHooks.ts", - "src\utils\hooks\apiQueryHookHelper.ts", - "src\utils\hooks\fileChangedWatcher.ts", - "src\commands\hooks\hooks.tsx", - "src\services\tools\toolHooks.ts", - "src\hooks\useDeferredHookMessages.ts", - "src\query\stopHooks.ts", - "src\query\stopHooks.test.ts", - "src\costHook.ts" -) - -$base = "E:\Yuanban\cc-haha-src" - -foreach ($f in $files) { - $full = Join-Path $base $f - if (Test-Path $full) { - $lines = @(Get-Content $full) - $outName = $f -replace '[\\/]', '_' - $outPath = Join-Path $outDir $outName - "FILE: $full" | Out-File $outPath -Encoding utf8 - "LINES: $($lines.Count)" | Out-File $outPath -Append -Encoding utf8 - "==========" | Out-File $outPath -Append -Encoding utf8 - Get-Content $full | Out-File $outPath -Append -Encoding utf8 - Write-Host "Copied: $f ($($lines.Count) lines) -> $outPath" - } else { - Write-Host "NOT FOUND: $full" - } -} - -Write-Host "DONE. Files in $outDir" From 715e24adcf94e2fd2b9287fffa4b3050100653bb Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Sat, 18 Jul 2026 15:58:03 +0800 Subject: [PATCH 47/48] fix: add cfg(target_os) guards to suppress dead_code warnings on Windows --- src/apps/cli/src/daemon/service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/apps/cli/src/daemon/service.rs b/src/apps/cli/src/daemon/service.rs index cae9471086..7a143b74ca 100644 --- a/src/apps/cli/src/daemon/service.rs +++ b/src/apps/cli/src/daemon/service.rs @@ -85,6 +85,7 @@ fn render_launch_agent(executable: &Path) -> String { ) } +#[cfg(target_os = "linux")] fn run_command(program: &str, args: &[&str]) -> Result { std::process::Command::new(program) .args(args) @@ -92,6 +93,7 @@ fn run_command(program: &str, args: &[&str]) -> Result { .with_context(|| format!("run `{program} {}`", args.join(" "))) } +#[cfg(target_os = "linux")] fn ensure_success(program: &str, args: &[&str]) -> Result<()> { let output = run_command(program, args)?; if output.status.success() { From 01e68adf9a7ee7dc2a3ae44fbfec8cf58d46cac6 Mon Sep 17 00:00:00 2001 From: BitFun Research Date: Mon, 20 Jul 2026 08:59:46 +0800 Subject: [PATCH 48/48] =?UTF-8?q?PR=20#1612:=20Hook=20framework=20enhancem?= =?UTF-8?q?ent=20=E2=80=94=20bee-review=20reminder-only=20mode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original (62cc648d7): - Added HookResult::Abort enum + BehaviorGuard + StopHook framework - FILE_READ_TRACKER: per-session file read recording - REVIEW_BUFFER: cc-haha pattern LLM review result buffer - Bee-review: async LLM review via std::thread::spawn after each tool round - Review dimensions: clerk (context guard) + inspector (behavior guard) + tip-bee (skill recommender) Fix — downgrade all ABORT to WARN-only: - execution_engine.rs: 5 ABORT rules changed to WARN in prompt, forbid ABORT output, interception injection changed to [review reminder] context - post_call_hooks.rs (assembly): bee-review ABORT result downgraded to WARN - post_call_hooks.rs (agent-runtime): C01 behavior_guard Abort changed to additional_contexts instead of aggregated.abort - tool_context_runtime.rs: HookResult::Abort changed to warn log only, tool result passes through unmodified Root cause: Original prompt "touched LionHeart library -> ABORT" conflicted with master-framework requiring LionHeart file reads. "PS/Chinese/JSON -> ABORT" blocked all PowerShell commands. Result: infinite ABORT loop, messages frozen. Design: Bee-review has reminder-only permission: tip-bee recommends skills, clerk guards context, inspector issues WARN. Zero interception. --- .../src/agentic/execution/execution_engine.rs | 27 ++++++++++--------- .../core/src/agentic/tools/post_call_hooks.rs | 7 ++--- .../src/agentic/tools/tool_context_runtime.rs | 22 +++++---------- .../agent-runtime/src/post_call_hooks.rs | 13 +++++++-- 4 files changed, 34 insertions(+), 35 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index b04f8599b1..fffa7462ac 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -3395,7 +3395,8 @@ impl ExecutionEngine { round_result.has_more_rounds, ); - // If C01 detected a violation, inject the abort message + // 审查蜂提醒模式:Abort 已在 post_call_hooks 降级为 WARN, + // 此处仅注入提醒上下文,不拦截任何操作 if aggregated.is_abort() { if let Some(abort) = &aggregated.abort { match abort { @@ -3404,14 +3405,14 @@ impl ExecutionEngine { fix_instruction, .. } => { - let guard_message = format!( - "[ToolGuard Intercepted] {reason}\n\n{fix_instruction}" + let remind = format!( + "[审查蜂提醒] {reason}\n\n建议: {fix_instruction}" ); let msg = - Message::system(render_system_reminder(&guard_message)); + Message::system(render_system_reminder(&remind)); messages.push(msg); warn!( - "[Stop Hook] Round {}: Abort — {}", + "[Stop Hook] Round {}: Reminder — {}", round_index, reason ); } @@ -3456,18 +3457,20 @@ impl ExecutionEngine { - 上下文压缩了?提取压缩前的关键决策/进度/用户纠正 → CTX:<要点>\n\ - 轮次>20或token告急?预摘要关键状态 → CTX:<摘要>\n\ - Agent忘记重要信息(重复提问/忽略决策)?→ CTX:<提醒>\n\n\ - ## 纪律委员(行为审查)\n\ - - 编辑前未Read?→ ABORT:未读即改-<文件>\n\ - - 碰了LionHeart库?→ ABORT:受保护路径\n\ - - 同工具连续失败?→ ABORT:策略死循环\n\ - - PS+中文+JSON?→ ABORT:PS编码风险\n\ - - 全部工具失败?→ ABORT:全部失败\n\ + ## 纪律委员(行为审查·仅提醒不拦截)\n\ + - 编辑前未Read?→ WARN:未读即改-<文件>\n\ + - 删除了LionHeart库文件?→ WARN:受保护路径-禁止删除\n\ + - LionHeart库读写操作正常,删除操作警告\n\ + - 同工具连续失败?→ WARN:策略死循环\n\ + - PS+中文+JSON?→ WARN:PS编码风险\n\ + - 全部工具失败?→ WARN:全部失败\n\ - 改完不验证?→ WARN:未验证\n\n\ ## 提示蜂(技能推荐)\n\ - MiniApp相关但没加载miniapp-dev?→ SKILL:miniapp-dev\n\ - 编码但没加载Pair-Programming?→ SKILL:Pair Programming\n\ - 调研但没加载agent-reach?→ SKILL:agent-reach\n\n\ - 输出(每行一个):PASS / ABORT:<原因> / WARN:<问题> / CTX:<内容> / SKILL:<名称>\n\ + 输出(每行一个):PASS / WARN:<问题> / CTX:<内容> / SKILL:<名称>\n\ + 禁止输出ABORT——审查蜂只有提醒权限,不拦截任何操作。\n\ 一切正常只回复PASS。", tl.join(","), fr2.join(","), fe2.join(","), ts ); diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index 89d906b5b0..5aa8fbf9ad 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -170,11 +170,8 @@ pub(crate) fn run_stop_hooks_for_round( continue; } if trimmed.starts_with("ABORT:") || trimmed.starts_with("ABORT:") { - result.abort = Some(HookResult::Abort { - reason: format!("[审查员] {}", trimmed), - fix_instruction: "按审查员建议修正后继续。".to_string(), - max_retries: 1, - }); + // 审查蜂无拦截权限,ABORT 降级为 WARN + result.additional_contexts.push(format!("[审查员] 提醒: {}", &trimmed[6..].trim())); } else if trimmed.starts_with("CTX:") || trimmed.starts_with("CTX:") { result.additional_contexts.push(format!("[书记官] 上下文恢复: {}", &trimmed[4..].trim())); } else if trimmed.starts_with("SKILL:") || trimmed.starts_with("SKILL:") { diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 9244c9b5df..5663d6b5d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -209,25 +209,15 @@ pub(crate) async fn call_with_tool_runtime_hooks( if result.is_ok() { let hook_result = post_call_hooks::record_successful_tool_call(tool_name, input, context); + // 审查蜂只有提醒权限,不拦截任何工具调用 if let bitfun_agent_runtime::post_call_hooks::HookResult::Abort { - reason, - fix_instruction, - .. + reason, .. } = hook_result { - let interception = serde_json::json!({ - "intercepted": true, - "reason": reason, - "fix_instruction": fix_instruction, - }); - return Ok(vec![ToolResult::Result { - data: interception, - result_for_assistant: Some(format!( - "[ToolGuard Intercepted] Result for tool {} was rejected.\nReason: {}\nFix: {}", - tool_name, reason, fix_instruction - )), - image_attachments: None, - }]); + log::warn!( + "[ToolGuard] Hook suggested abort for tool '{}': {reason} — ignored (reminder-only mode)", + tool_name + ); } } diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index 1cf230fbb6..fbb963afb2 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -315,10 +315,19 @@ pub fn run_stop_hooks( } } - // C01 审查蜂: iron-rule violation check (blocking on violation) + // C01 审查蜂: iron-rule violation check (提醒模式,不拦截) let c01 = executor.behavior_guard(ctx); if c01.is_abort() { - aggregated.abort = Some(c01); + if let HookResult::Abort { + reason, + fix_instruction, + .. + } = c01 + { + aggregated.additional_contexts.push(format!( + "[C01 审查蜂] 提醒: {reason} — {fix_instruction}" + )); + } } aggregated