Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,11 @@ GUI 和 TUI 都通过同一个 `SetSafeMode` 动作请求该变化,Peer Host
### 3.4 静态 Hook 目录

Hook 首先以独立、只读的 `ExternalHookCatalogSnapshotV1` 展示,而不进入可执行来源管理模块。Desktop 设置页的
“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks` 消费同一份 Rust 快照;`/help hooks`、`/hooks -h` 与 `/hooks --help` 提供说明,不增加快捷键、
命名空间变体或生态专用命令。`/hooks` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生
“外部 AI 应用 → Hooks”和交互式 TUI 的 `/hooks_external`(别名 `/hooks-external`)消费同一份 Rust 快照;
`/help hooks_external`、`/hooks_external -h` 与 `/hooks_external --help` 提供说明,不增加快捷键、
命名空间变体或生态专用命令。TUI 的 `/hooks` 属于 BitFun 自身会执行的 Hooks(见
[agent-hooks](../../features/agent-hooks.zh-CN.md)),与本节的静态目录是两个视图,彼此在帮助文本中互相指向。
`/hooks_external` 与其他内置命令采用同一套既有冲突策略:无冲突时使用普通命令名;发生
同名冲突时由现有命令菜单展示来源限定项,静态 Hook 目录不增加另一套保留字或路由规则。

当前目录的来源与降级边界如下:
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/product-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ flowchart LR
| 当前入口 | 已有能力 | 明确边界 |
|---|---|---|
| Desktop | 使用 `product-full`;显示外部来源、审批、冲突、诊断和 Host 能力 | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 |
| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 |
| CLI / TUI | 使用 `product-full`;提供 `/extensions`、`/hooks_external`、`/tools` 和 `/agents` | 不解析生态文件,不启动第二套 Agent Runtime;远程能力未接入时不回退本机 |
| ACP | 使用 `DeliveryProfile::Acp` 和 Runtime Parts | load 成功后才发布活动状态;close 排空后再卸载;完整历史和配置仍由 Core/ACP 管理 |
| Peer / Server | Server 提供 control/catalog;Peer Host 执行真实工作区操作 | 控制端不替远端发现或执行;旧 Host 明确降级,SSH Remote 未接入时返回不支持 |
| Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 |
Expand Down
9 changes: 6 additions & 3 deletions docs/features/agent-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ log level.

## Related

- CLI `/hooks` inspects hooks configured for *other* AI applications (Claude
Code, Codex, OpenCode). That view is read-only and never executes anything;
the hooks described here are BitFun's own and do execute.
- CLI `/hooks` shows the hooks described here — which files they came from,
which layers are active, and what each matcher group would run. It reports
the configuration only; edit `hooks.json` to change it.
- CLI `/hooks_external` inspects hooks configured for *other* AI applications
(Claude Code, Codex, OpenCode). That view is read-only and never executes
anything; the hooks described here are BitFun's own and do execute.
8 changes: 5 additions & 3 deletions docs/features/agent-hooks.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ Hook 是以你的用户权限运行的任意代码,且每次对应事件触发

## 相关

- CLI 的 `/hooks` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode)配置的
Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks,它们会
真正执行。
- CLI 的 `/hooks` 展示的就是本文描述的 Hooks:来自哪些文件、哪些层级生效、每个
匹配组会运行什么。它只报告配置,修改请直接编辑 `hooks.json`。
- CLI 的 `/hooks_external` 用于查看*其他* AI 应用(Claude Code、Codex、OpenCode)
配置的 Hooks。该视图只读,不会执行任何内容;本文描述的是 BitFun 自身的 Hooks,
它们会真正执行。
23 changes: 20 additions & 3 deletions src/apps/cli/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ pub(crate) enum ActionHandler {
McpServers,
Tools,
Extensions,
Hooks,
NativeHooks,
ExternalHooks,
AcpHelp,
Init,
History,
Expand Down Expand Up @@ -444,10 +445,25 @@ static ACTION_SPECS: &[ActionSpec] = &[
id: "hooks",
name: "Hooks",
aliases: &["/hooks"],
description: "View BitFun Hooks for agent lifecycle events",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::NativeHooks,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
palette: palette("Tools", false),
shortcut_label: None,
slash_on_startup: false,
},
ActionSpec {
id: "hooks_external",
name: "External Hooks",
aliases: &["/hooks_external", "/hooks-external"],
description: "Inspect external AI application Hooks",
contexts: CHAT,
availability: ActionAvailability::Always,
handler: ActionHandler::Hooks,
handler: ActionHandler::ExternalHooks,
default_bindings: &[],
fallback_bindings: &[],
shortcut_field: None,
Expand Down Expand Up @@ -1747,7 +1763,8 @@ mod tests {
ActionHandler::McpServers,
ActionHandler::Tools,
ActionHandler::Extensions,
ActionHandler::Hooks,
ActionHandler::NativeHooks,
ActionHandler::ExternalHooks,
ActionHandler::Login,
ActionHandler::Usage,
] {
Expand Down
4 changes: 4 additions & 0 deletions src/apps/cli/src/modes/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ use bitfun_core::external_sources::{
ExternalToolCatalogEntry, ExternalToolRuntimeKind, NativePromptCommandDescriptor,
PromptCommandAvailability, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1,
};
use bitfun_core::native_hooks::{
overview as native_hook_overview, NativeHookOverview, NativeHookRuleView,
};
use bitfun_core::product_runtime::CoreAgentRuntimeCompatibility;
use bitfun_core::service::config::GlobalConfigManager;
use bitfun_core::service::session_usage::render_usage_report_markdown;
Expand All @@ -95,6 +98,7 @@ const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75;
include!("chat/external_review.rs");
include!("chat/external_sources.rs");
include!("chat/external_hooks.rs");
include!("chat/native_hooks.rs");

fn agent_event_stream_failure(error: TryRecvError) -> Option<String> {
match error {
Expand Down
7 changes: 5 additions & 2 deletions src/apps/cli/src/modes/chat/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -683,7 +683,10 @@ impl ChatMode {
ActionHandler::Extensions => {
self.handle_external_control("", chat_view, chat_state, rt_handle);
}
ActionHandler::Hooks => {
ActionHandler::NativeHooks => {
self.handle_native_hooks(chat_view, chat_state, rt_handle);
}
ActionHandler::ExternalHooks => {
self.handle_external_hooks(chat_view, chat_state, rt_handle);
}
ActionHandler::AcpHelp => {
Expand Down Expand Up @@ -879,7 +882,7 @@ fn action_opens_extension_management(action: &ActionSpec) -> bool {
action.handler,
ActionHandler::Tools
| ActionHandler::Extensions
| ActionHandler::Hooks
| ActionHandler::ExternalHooks
| ActionHandler::OpenAgentSelector
)
}
17 changes: 10 additions & 7 deletions src/apps/cli/src/modes/chat/external_hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@ const MAX_TUI_HOOK_CATALOG_DIAGNOSTICS: usize = 20;

fn external_hook_help_text() -> String {
[
"Hooks",
"External Hooks",
"",
"Usage: /hooks",
"Usage: /hooks_external",
"Alias: /hooks-external",
"",
"Shows a read-only static catalog of Hooks configured for OpenCode, Claude Code, and Codex.",
"BitFun does not load or run handlers from this view. Coverage mapped means BitFun recognizes an equivalent reviewed Hook point; it does not mean the native handler is active.",
"BitFun's own Hooks, which do run, are shown by /hooks.",
"",
"Help: /help hooks, /hooks -h, or /hooks --help",
"Help: /help hooks_external, /hooks_external -h, or /hooks_external --help",
]
.join("\n")
}
Expand All @@ -27,7 +29,8 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option
return None;
};
match requested.to_ascii_lowercase().as_str() {
"hooks" => Some(external_hook_help_text()),
"hooks" => Some(native_hook_help_text()),
"hooks_external" | "hooks-external" => Some(external_hook_help_text()),
"extensions" => Some([
"External integrations",
"",
Expand Down Expand Up @@ -72,12 +75,12 @@ fn extension_command_help_request(command_name: &str, arguments: &str) -> Option

fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> String {
let mut lines = vec![
"Hooks (read-only)".to_string(),
"External Hooks (read-only)".to_string(),
"Static configuration only; no handler was loaded or executed.".to_string(),
String::new(),
];
if snapshot.discovery_pending {
lines.push("Hook discovery is still pending. Run /hooks again.".to_string());
lines.push("Hook discovery is still pending. Run /hooks_external again.".to_string());
return lines.join("\n");
}
if snapshot.sources.is_empty()
Expand Down Expand Up @@ -222,7 +225,7 @@ fn render_external_hook_catalog(snapshot: &ExternalHookCatalogSnapshotV1) -> Str
}
lines.push(String::new());
lines.push(
"Edit Hooks in the source application's configuration. Help: /help hooks, /hooks -h, or /hooks --help"
"Edit Hooks in the source application's configuration. BitFun's own Hooks: /hooks. Help: /help hooks_external, /hooks_external -h, or /hooks_external --help"
.to_string(),
);
lines.join("\n")
Expand Down
189 changes: 189 additions & 0 deletions src/apps/cli/src/modes/chat/native_hooks.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
const MAX_TUI_NATIVE_HOOK_RULES: usize = 100;
const MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE: usize = 20;
const MAX_TUI_NATIVE_HOOK_ISSUES: usize = 20;
const MAX_TUI_NATIVE_HOOK_COMMAND_CHARS: usize = 200;

fn native_hook_help_text() -> String {
[
"Hooks",
"",
"Usage: /hooks",
"",
"Shows the BitFun Hooks configured for agent lifecycle events, which files they came from, and whether each layer is active.",
"Hooks run your own commands, so this view only reports the configuration; edit hooks.json to change it.",
"Hooks configured for other AI applications are a separate read-only catalog: /hooks_external.",
"",
"Help: /help hooks, /hooks -h, or /hooks --help",
]
.join("\n")
}

fn truncate_hook_command(command: &str) -> String {
let command = command.trim();
if command.chars().count() <= MAX_TUI_NATIVE_HOOK_COMMAND_CHARS {
return command.to_string();
}
let kept = command
.chars()
.take(MAX_TUI_NATIVE_HOOK_COMMAND_CHARS)
.collect::<String>();
format!("{kept}…")
}

fn native_hook_rule_line(rule: &NativeHookRuleView) -> String {
format!(
" matcher: {} [{}; {} handler{}{}]",
rule.matcher,
rule.scope,
rule.handlers.len(),
plural(rule.handlers.len()),
if rule.matcher_is_valid {
""
} else {
"; invalid pattern, never matches"
},
)
}

fn render_native_hook_overview(overview: &NativeHookOverview) -> String {
let mut lines = vec![
"Hooks (BitFun)".to_string(),
"Commands BitFun runs at agent lifecycle events. Nothing was executed to build this view."
.to_string(),
String::new(),
];
lines.push(format!(
"Hooks: {} (app.hooks.enabled)",
if overview.enabled {
"enabled"
} else {
"disabled"
}
));
lines.push(format!(
"Project hooks: {} (app.hooks.project_hooks_enabled)",
if overview.project_hooks_enabled {
"enabled"
} else {
"disabled"
}
));

lines.push(String::new());
if overview.files.is_empty() {
lines.push("No hook configuration path is available on this host.".to_string());
} else {
lines.push("Configuration:".to_string());
for file in &overview.files {
lines.push(format!(
" {} [{}; {}]: {}",
file.scope,
if file.loaded { "loaded" } else { "not loaded" },
if file.exists { "present" } else { "missing" },
file.path.display(),
));
}
}

lines.push(String::new());
if !overview.enabled {
lines.push("All hooks are off; set app.hooks.enabled to run them.".to_string());
} else if overview.rules.is_empty() {
lines.push("No hooks are configured.".to_string());
} else {
lines.push(format!(
"{} matcher group{}, {} handler{}:",
overview.rules.len(),
plural(overview.rules.len()),
overview.total_handlers,
plural(overview.total_handlers),
));
let mut current_event = "";
for rule in overview.rules.iter().take(MAX_TUI_NATIVE_HOOK_RULES) {
if rule.event != current_event {
current_event = rule.event;
lines.push(String::new());
lines.push(rule.event.to_string());
}
lines.push(native_hook_rule_line(rule));
for handler in rule
.handlers
.iter()
.take(MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE)
{
lines.push(format!(
" - {} [timeout {}s{}]",
truncate_hook_command(&handler.command),
handler.timeout_seconds,
match handler.status_message.as_deref() {
Some(message) if !message.trim().is_empty() =>
format!("; status: {}", message.trim()),
_ => String::new(),
},
));
}
let omitted_handlers = rule
.handlers
.len()
.saturating_sub(MAX_TUI_NATIVE_HOOK_HANDLERS_PER_RULE);
if omitted_handlers > 0 {
lines.push(format!(" … omitted {omitted_handlers} handler(s)."));
}
}
let omitted_rules = overview
.rules
.len()
.saturating_sub(MAX_TUI_NATIVE_HOOK_RULES);
if omitted_rules > 0 {
lines.push(String::new());
lines.push(format!(
"… omitted {omitted_rules} matcher group(s); open the hook files for the full configuration."
));
}
}

if !overview.issues.is_empty() {
lines.push(String::new());
lines.push("Configuration issues:".to_string());
for issue in overview.issues.iter().take(MAX_TUI_NATIVE_HOOK_ISSUES) {
lines.push(format!(" ! {issue}"));
}
if overview.issues.len() > MAX_TUI_NATIVE_HOOK_ISSUES {
lines.push(format!(
" … {} additional issue(s) omitted.",
overview.issues.len() - MAX_TUI_NATIVE_HOOK_ISSUES
));
}
}

lines.push(String::new());
lines.push(
"Edit hooks.json to change this. Hooks configured for other AI applications: /hooks_external. Help: /help hooks, /hooks -h, or /hooks --help"
.to_string(),
);
lines.join("\n")
}

impl ChatMode {
fn handle_native_hooks(
&mut self,
chat_view: &mut ChatView,
chat_state: &mut ChatState,
rt_handle: &tokio::runtime::Handle,
) {
let workspace_root = self.workspace_path_for_sync(chat_state);
let overview = tokio::task::block_in_place(|| {
rt_handle.block_on(native_hook_overview(Some(workspace_root.as_path())))
});
chat_view.set_status(Some(if overview.enabled {
format!(
"Hooks: {} handler{}",
overview.total_handlers,
plural(overview.total_handlers)
)
} else {
"Hooks are disabled".to_string()
}));
chat_state.add_system_message(render_native_hook_overview(&overview));
}
}
Loading