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 @@ -479,9 +479,14 @@ Plugin Host Runtime、已退役的 LSP Runtime,以及通用动态模型路由
Claude runtime 变量与动态 shell 表达式保留为未展开、未执行的文本,加载说明明确它们不是实际值或命令结果;
如任务需要这些数据,Agent 必须通过当前工作区的正常工具与权限流程获取。行内 shell 表达式的识别要求行首或空白边界及闭合
反引号;普通 Markdown 中的感叹号、Excel `Sheet1!A1` 和错误值不会触发兼容性警告。
`context`(包括 `fork`)、`agent`、`hooks`、`paths`、`shell`、`runtime`、`background`、`disallowed-tools`
`context`(包括 `fork`)、`agent`、`paths`、`shell`、`runtime`、`background`、`disallowed-tools`
涉及未实现的执行方式或约束,仍阻止加载;格式损坏及无效调用控制字段也仍返回错误。本地与 Remote、名称与稳定键加载共享
同一兼容性判断和模型说明。此切片不增加插件 Skill、祖先活动目录、文件 watcher、URL 来源或另一条 reload 命令。
- Claude Skill 的同步 command `hooks` 在显式 Skill 调用时注册到会话的共享 HookRegistry,扫描与导入不执行。
Skill 调用形成工具预检边界,同轮后续工具也接受新增 Hook;Bash/ExecCommand 与 Write 参数由方言适配转换,
ask 进入现有权限邮箱,deny/exit 2 阻断,updatedInput 继续接受原参数约束和最终校验。
once、会话隔离、幂等注册与退出取消由现有 portable Hook 引擎持有;配置 gating 与生命周期清理在 Core owner。
Remote 工作区激活明确不支持,其他控制面复用目标 runtime;详见 [Agent Hooks](../../features/agent-hooks.zh-CN.md)。
- Claude Subagent 扫描用户与逐层项目 `.claude/agents/**/*.md`,近工作目录定义整项覆盖;Claude MCP 保留
`local > project > user` 的整项覆盖,local 只读取与规范化当前工作区严格匹配的项目项。
- Codex Subagent 从用户与逐层项目 `[agents]`、角色文件合并,缺失字段按 Codex 层级继承;`enabled`、默认模型、角色级
Expand Down
42 changes: 42 additions & 0 deletions docs/features/agent-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,48 @@ the same backend and keeps `/hooks_external` and `/hooks-external` as aliases
for the unified `/hooks` management view. `reset` is available only as explicit
recovery for a corrupt OpenBitFun-managed index and never changes source files.

## Hooks declared by skills

Invoking a Claude-format skill through `Skill` registers its validated synchronous
`type: "command"` handlers in the existing Hook engine for that session. Discovery,
listing, and importing do not register or execute them. Imported Claude skills
retain their source dialect. The external Hook catalog remains read-only.

Skill hooks use the supported lifecycle events listed above, regular-expression
matchers, stdin JSON, exit-code blocking, and `updatedInput`. They run after the
configured command layers. Registration is idempotent; invoking a changed hook
declaration in the same session returns an error instead of replacing active rules.
`once: true` is consumed after exit code 0, atomically across concurrent dispatches;
exit 2, other failures, and timeouts leave it eligible. A skill loaded mid-batch is
a preflight barrier: later calls see its hooks even within the same model response.

The Claude adapter maps `Bash` to `ExecCommand` and `command` to `cmd`. For `Write`,
it translates the path-first `payload` into `file_path`/`content` and converts
`updatedInput` back before normal input validation. An ambiguous Write destination
is blocked while a matching skill hook is active. `Edit` keeps its existing fields.
The command receives `CLAUDE_SKILL_DIR`, `CLAUDE_SESSION_ID`, and
`CLAUDE_PROJECT_DIR`; this does not expand variables in the skill's prose.

Skill `PreToolUse` hooks also support Claude's `permissionDecision: "ask"` through
the existing session permission mailbox. An ask requires a fresh reply even in
bypass mode; a policy deny still wins. The hook reason is included in the approval
metadata. Native `hooks.json` keeps its Codex decision contract.

The master `app.hooks.enabled` gate applies. Project skills additionally require
`app.hooks.project_hooks_enabled`, including on subsequent dispatch. Activation
without a session/local workspace, or in an SSH/remote workspace, returns an
explicit error; no controller-local fallback executes. Remote control, Peer Device,
and Detached Dispatch reuse their target runtime's session and permission owners;
this change does not introduce a separate client-side hook runner.

Registrations survive ordinary turns and idle in-process session unloading. Session
end/delete/discard removes them and cancels running handlers; cancelled SessionEnd
dispatch also clears them. They are process-local and are not restored after a
runtime restart: invoke the skill again. Unknown events, asynchronous handlers,
`prompt`/`agent` handlers, and unknown execution fields reject the entire skill
rather than silently dropping constraints. Script files remain live dependencies,
as for native command hooks; the declaration fingerprint does not snapshot scripts.

## Quick start

Create `<user config dir>/config/hooks.json`:
Expand Down
32 changes: 32 additions & 0 deletions docs/features/agent-hooks.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ openbitfun hooks reset <user|project> --confirm
`/hooks_external`、`/hooks-external` 作为统一 `/hooks` 管理视图的兼容别名。
`reset` 只用于显式恢复损坏的 OpenBitFun 托管索引,绝不会修改来源文件。

## 技能中的 Hooks

通过 `Skill` 调用 Claude 格式技能时,经过完整校验的同步 `type: command`
处理器会注册到现有 Hook 引擎,归属于当前会话。扫描、列表和导入不注册、不执行;
导入后的 Claude 技能保留来源方言,外部 Hook 目录仍然只是只读发现数据。

技能使用上文已支持的生命周期事件、正则 matcher、stdin JSON、退出码阻断和
`updatedInput`。执行顺序在配置的 command 层之后。同一会话重复加载不重复注册;
声明改变时明确报错,不静默替换已激活规则。`once: true` 仅在退出码为 0 后消费,
并发派发也只成功执行一次;退出码 2、其他失败和超时不消费。
同一模型响应先调用技能、再调用工具时,后续工具会等技能完成后再执行 Hook 与权限预检。

Claude 适配将 `Bash` 匹配到 `ExecCommand`,并双向转换 `command/cmd`。
`Write` 的路径前缀 `payload` 会转换成 `file_path/content`,返回的 `updatedInput`
再转换回本地格式并接受正常校验;有匹配的技能 Hook 时,无法确定写入目标的调用会被阻断。
`Edit` 沿用原参数。命令环境包含 `CLAUDE_SKILL_DIR`、`CLAUDE_SESSION_ID`、
`CLAUDE_PROJECT_DIR`;技能正文中的变量仍不会因此展开。

技能 `PreToolUse` 的 `permissionDecision: ask` 进入现有会话权限邮箱,携带 Hook
原因;即使启用了自动批准,也需要用户本次答复,已有权限拒绝仍然优先。
原生 `hooks.json` 保持 Codex 决策契约。

激活受 `app.hooks.enabled` 控制;项目技能还要求 `app.hooks.project_hooks_enabled`,
后续派发也遵守该开关。缺少所属会话、本地工作区或处于 SSH/远程工作区时明确拒绝激活,
不会回退到控制端本地执行。远程控制、Peer Device、Detached Dispatch 复用目标运行时
已有的会话和权限 owner,不增加客户端执行器。

注册跨普通回合及进程内空闲卸载保留;会话结束、删除或临时会话丢弃时清除并取消正在运行的
处理器,SessionEnd 派发被取消时也会清理。状态只存在于运行时内存,进程重启后需要重新调用技能。
未知事件、异步、`prompt/agent` 类型和未知执行字段会使整份技能加载失败,不会只丢弃部分约束。
脚本文件与原生命令 Hook 一样是实时依赖,声明指纹不代表脚本快照。

## 快速开始

创建 `<用户配置目录>/config/hooks.json`:
Expand Down
2 changes: 1 addition & 1 deletion scripts/core-boundaries/cargo-dependency-boundaries.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ const CORE_TOKIO_AGGREGATES = new Set([
'tools-mcp',
]);
const AGENT_RUNTIME_TOKIO_FEATURES = new Map([
['native-hook-runtime', ['io-util', 'macros', 'process', 'rt', 'time']],
['native-hook-runtime', ['io-util', 'macros', 'process', 'rt', 'sync', 'time']],
['agent-runtime', ['io-util', 'macros', 'process', 'rt', 'sync', 'time']],
]);

Expand Down
7 changes: 6 additions & 1 deletion scripts/core-boundaries/rules/feature-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export const optionalDependencyFeatureOwnerRules = [
{ depName: 'log', ownerFeatures: ['agent-runtime', 'native-hook-runtime'] },
{ depName: 'regex', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-settings'] },
{ depName: 'serde', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] },
{ depName: 'serde_json', ownerFeatures: ['agent-runtime', 'native-hook-runtime', 'native-hook-settings'] },
{ depName: 'serde_json', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime', 'native-hook-settings'] },
{ depName: 'serde_yaml', ownerFeatures: ['agent-runtime', 'definition-contracts'] },
{ depName: 'sha2', ownerFeatures: ['agent-runtime'] },
{ depName: 'thiserror', ownerFeatures: ['agent-runtime', 'definition-contracts', 'native-hook-runtime'] },
Expand Down Expand Up @@ -604,9 +604,12 @@ export const capabilityContractDependencyRules = [
featureProfiles: {
default: [],
'definition-contracts': [
// Skill declarations reuse pure hook validation without process support.
'native-hook-settings',
'dep:openbitfun-core-types',
'dep:regex',
'dep:serde',
'dep:serde_json',
'dep:serde_yaml',
'dep:thiserror',
],
Expand All @@ -623,6 +626,8 @@ export const capabilityContractDependencyRules = [
'tokio/macros',
'tokio/process',
'tokio/rt',
// Session hook once guards and cancellation watchers.
'tokio/sync',
'tokio/time',
],
'agent-runtime': [
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/skill_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1604,6 +1604,7 @@ mod tests {
path: "/remote/denied".into(),
source_id: "codex".into(),
message: "permission denied".into(),
unsupported_field: None,
},
],
};
Expand Down
7 changes: 7 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ Skill discovery, installation provenance, and local/remote registry regressions:
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::implementations::skills::
```

Skill hook activation, session cleanup, and tool preflight/permission ordering:

```bash
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib native_hooks
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::pipeline::tool_pipeline::tests
```

For configured OpenCode discovery and explicit skill loading, include their owner feature and tool tests:

```bash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5016,6 +5016,7 @@ impl SessionManager {
}
}

crate::native_hooks::clear_session_hook_state(session_id);
self.active_turn_permission_modes.remove(session_id);
clear_session_runtime_stores(
session_id,
Expand Down
6 changes: 6 additions & 0 deletions src/crates/assembly/core/src/agentic/tools/framework.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,12 @@ pub trait Tool: Send + Sync {
self.is_readonly()
}

/// Subsequent calls must be preflighted after this tool finishes because
/// it can change session execution policy (for example, activate hooks).
fn invalidates_tool_preflight(&self) -> bool {
false
}

/// Describe permission actions and resources without performing side effects.
fn permission_intents(
&self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,10 @@ impl Tool for SkillTool {
}

fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool {
false
}

fn invalidates_tool_preflight(&self) -> bool {
true
}

Expand Down Expand Up @@ -299,6 +303,8 @@ impl Tool for SkillTool {
}
};

crate::native_hooks::activate_skill_hooks(&skill_data, context).await?;

if let Some(arguments) = input.get("arguments").and_then(Value::as_str) {
skill_data.content = expand_prompt_template_arguments_with_names(
&skill_data.content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,44 @@ mod local_skill_scan_tests {
);
}

#[tokio::test]
async fn claude_scan_distinguishes_unsupported_fields_from_invalid_markdown() {
let temp = tempfile::tempdir().unwrap();
let root_path = temp.path().join("skills");
write_skill(&root_path.join("good"));
for (name, markdown) in [
(
"guard",
"---\nname: guard\ndescription: Guard tools.\ncontext: fork\n---\n",
),
("broken", "---\nname: broken\n---\n"),
] {
let directory = root_path.join(name);
fs::create_dir_all(&directory).unwrap();
fs::write(directory.join("SKILL.md"), markdown).unwrap();
}
let mut root = test_root(root_path);
root.slot = "home.claude";
root.source_id = "claude-code";
let scan = SkillRegistry::scan_skills_in_dir(&root).await;
assert_eq!(scan.candidates.len(), 1);
assert_eq!(scan.diagnostics.len(), 2);
let unsupported = scan
.diagnostics
.iter()
.find(|item| item.unsupported_field.is_some())
.unwrap();
assert_eq!(unsupported.unsupported_field.as_deref(), Some("context"));
assert!(unsupported.path.ends_with("guard/SKILL.md"));
let failure = scan
.diagnostics
.iter()
.find(|item| item.unsupported_field.is_none())
.unwrap();
assert!(failure.path.ends_with("broken/SKILL.md"));
assert!(failure.message.contains("description"));
}

#[tokio::test]
async fn claude_config_root_keeps_source_identity_and_rejects_relative_roots() {
let temp = tempfile::tempdir().unwrap();
Expand Down Expand Up @@ -1116,6 +1154,7 @@ impl SkillRegistry {
path,
source_id: "pi".into(),
message,
unsupported_field: None,
}
}));
let pi_scan =
Expand All @@ -1133,6 +1172,7 @@ impl SkillRegistry {
path,
source_id: "opencode".to_string(),
message,
unsupported_field: None,
}
}));
let configured_scan =
Expand Down Expand Up @@ -2501,6 +2541,7 @@ mod remote_scan_tests {
calls: AtomicUsize,
installation_lock: Option<String>,
pi_settings: Option<String>,
claude_hooks: bool,
}

impl DelayedFs {
Expand Down Expand Up @@ -2537,6 +2578,11 @@ mod remote_scan_tests {
if path.ends_with("openai.yaml") {
return Ok("policy:\n allow_implicit_invocation: false\n".into());
}
if self.claude_hooks && path.ends_with("/.claude/skills/skill-00/SKILL.md") {
return Ok(
"---\nname: skill-00\ndescription: Guard tools.\ncontext: fork\n---\n".into(),
);
}
let name = path.rsplit('/').nth(1).unwrap();
Ok(format!(
"---\nname: {name}\ndescription: {path}\n---\nBody\n"
Expand All @@ -2562,6 +2608,7 @@ mod remote_scan_tests {
self.round_trip().await;
Ok(path.contains("/.openbitfun/")
|| path.contains("/.codex/")
|| (self.claude_hooks && path.contains("/.claude/"))
|| (self.installation_lock.is_some() && path.contains("/.agents/")))
}
async fn read_dir(&self, path: &str) -> anyhow::Result<Vec<WorkspaceDirEntry>> {
Expand All @@ -2579,6 +2626,27 @@ mod remote_scan_tests {
}
}

#[tokio::test]
async fn remote_claude_scan_reports_unsupported_fields_without_loading_the_skill() {
let fs = DelayedFs {
claude_hooks: true,
..Default::default()
};
let scan = SkillRegistry::scan_remote_project_skills(&fs, "/remote/project").await;
assert_eq!(scan.candidates.len(), 38);
assert_eq!(scan.diagnostics.len(), 1);
let diagnostic = &scan.diagnostics[0];
assert_eq!(
diagnostic.path,
"/remote/project/.claude/skills/skill-00/SKILL.md"
);
assert_eq!(diagnostic.unsupported_field.as_deref(), Some("context"));
assert!(!scan
.candidates
.iter()
.any(|candidate| candidate.info.path == "/remote/project/.claude/skills/skill-00"));
}

#[tokio::test]
async fn remote_pi_settings_paths_report_unsupported_without_local_substitution() {
let fs = DelayedFs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ pub(super) fn diagnostic(
path: path.into(),
source_id: source_id.into(),
message: message.to_string(),
unsupported_field: None,
}
}

Expand Down Expand Up @@ -482,11 +483,13 @@ impl SkillRegistry {
candidate.info.import_origin = import_origin;
scan.candidates.push(candidate);
}
Err(error) => scan.diagnostics.push(diagnostic(
&skill_md,
entry.source_id,
error,
)),
Err(error) => scan.diagnostics.push(
SkillScanDiagnostic::from_parse_error(
&skill_md,
entry.source_id,
&error,
),
),
}
}
Ok(None) => scan.diagnostics.push(diagnostic(
Expand Down Expand Up @@ -811,11 +814,13 @@ impl SkillRegistry {
candidate.info.import_origin = import_origin;
scan.candidates.push(candidate);
}
Err(error) => scan.diagnostics.push(diagnostic(
skill_md.to_string_lossy(),
entry.source_id,
error,
)),
Err(error) => {
scan.diagnostics.push(SkillScanDiagnostic::from_parse_error(
skill_md.to_string_lossy(),
entry.source_id,
&error,
))
}
}
}
// A skill is a package boundary; its reference examples
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,9 @@ mod tests {
async fn imported_package_preserves_dialect_assets_identity_and_runtime_selection() {
let temp = tempfile::tempdir().unwrap();
let source = source(temp.path()).await;
let skill_file = Path::new(&source.path).join("SKILL.md");
let markdown = fs::read_to_string(&skill_file).await.unwrap();
fs::write(&skill_file, markdown.replacen("---", "---\nhooks:\n PreToolUse:\n - matcher: Bash\n hooks:\n - type: command\n command: exit 2", 1)).await.unwrap();
let target = temp.path().join("native");
let origin = import_copy(source.clone(), target.clone()).await.unwrap();
let native = SkillRegistry::scan_skills_in_dir(&root(
Expand All @@ -601,6 +604,7 @@ mod tests {
native.info.parser_source_slot(),
)
.unwrap();
assert!(loaded.hooks.as_ref().is_some_and(|hooks| !hooks.is_empty()));
assert_eq!(loaded.name, "demo");
assert!(content.contains("scripts/tool.py"));
assert!(target.join("demo/scripts/tool.py").is_file());
Expand Down
Loading
Loading