fix(runtime): hooks fire on Runtime API threads; GET /v1/hooks

Runtime API threads — the path the desktop app and web drive — built
their engines with `hook_executor: None` and sent every turn with
`hook_executor: None`, so configured hooks never ran there: no
tool_call_before gate, no shell_env, no tool_call_after (sweep B4).

- Each Runtime thread's engine gets a HookExecutor for its workspace from
  the same loader the TUI and `exec --hooks` use (global config, reviewed
  plugins, trusted and approved project hooks). The executor is kept on
  the thread's active state and carried on every turn op, which
  re-installs it into the engine. Executors are rebinds of one per-process
  base, so threads share one observer pool.
- The runtime turn monitor fires tool_call_after for every tool completion
  and on_error for a failed one, as the TUI does.
- GET /v1/hooks[?thread_id=] lists that hook set (name, event, command
  with credential-shaped values masked, background, timeout, source
  global/plugin/project) and load problems, so clients stop keeping a
  hook table of their own.
- docs/HOOKS.md scope table and docs/RUNTIME_API.md updated.

Not covered: UI-level events (session_start, message_submit, turn_end)
still fire only in the TUI.

Tests: new runtime_threads::tests::runtime_tool_completion_fires_after_and_error_hooks
and runtime_api::tests::command_catalog::get_v1_hooks_serves_the_runtime_hook_set
pass; focused run 1099 passed / 0 failed / 4 ignored (runtime_threads::,
hooks::, command_catalog included).

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-25 13:41:09 -07:00
1 parent ed241c3a7f
commit 1700c556eb
7 files changed
+349 -12

No files matched your search

+6
View File
@@ -1624,6 +1624,12 @@ impl HookExecutor {
&self.config
}
/// The workspace hooks run in unless a hook names its own directory.
#[must_use]
pub fn default_working_dir(&self) -> &std::path::Path {
&self.default_working_dir
}
pub fn session_id(&self) -> &str {
&self.session_id
}
+86
View File
@@ -1421,6 +1421,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/commands", get(list_commands))
.route("/v1/hooks", get(list_hooks))
.route(
"/v1/skills/{name}",
post(set_skill_enabled).delete(uninstall_skill_api),
@@ -3510,6 +3511,91 @@ async fn list_commands(
Ok(Json(CommandsResponse { commands }))
}
#[derive(Debug, Deserialize)]
struct HooksQuery {
/// Report the hooks a thread's engine runs (its workspace); defaults to
/// the server workspace.
thread_id: Option<String>,
}
#[derive(Debug, Serialize)]
struct HooksResponse {
workspace: String,
enabled: bool,
hooks: Vec<HookEntry>,
/// Hooks rejected or warned about at load, one redaction-safe line each.
problems: Vec<String>,
}
#[derive(Debug, Serialize)]
struct HookEntry {
name: Option<String>,
event: &'static str,
/// The shell command, with credential-shaped values masked.
command: String,
background: bool,
timeout_secs: u64,
/// `global` (user config), `plugin` (reviewed plugin) or `project`
/// (trusted, approved `.codewhale/hooks.toml`).
source: &'static str,
}
/// `GET /v1/hooks` (B4): the hook set Runtime API threads run, from the same
/// loader their engines use, so clients show one truth instead of keeping a
/// hook table of their own.
async fn list_hooks(
State(state): State<RuntimeApiState>,
Query(query): Query<HooksQuery>,
) -> Result<Json<HooksResponse>, ApiError> {
let workspace = match query.thread_id.as_deref() {
Some(id) => {
state
.runtime_threads
.get_thread(id)
.await
.map_err(map_thread_err)?
.workspace
}
None => state.workspace.clone(),
};
let config = state.config.read().clone();
let plugins = state.plugin_discovery.registry_for_workspace(&workspace);
let executor = state.runtime_threads.hook_executor_for_workspace(
&config,
&workspace,
Some(plugins.as_ref()),
);
let hooks_config = executor.config();
let hooks = hooks_config
.hooks
.iter()
.map(|hook| HookEntry {
name: hook.name.clone(),
event: hook.event.as_str(),
command: codewhale_config::persistence::redact_secrets(&hook.command),
background: hook.background,
timeout_secs: hook.timeout_secs,
source: if hook.project_authority.is_some() {
"project"
} else if hook.plugin_authority.is_some() {
"plugin"
} else {
"global"
},
})
.collect();
Ok(Json(HooksResponse {
workspace: workspace.display().to_string(),
enabled: hooks_config.enabled,
hooks,
problems: hooks_config
.problems
.iter()
.map(crate::hooks::HookConfigProblem::summary)
.collect(),
}))
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
@@ -140,3 +140,69 @@ async fn get_v1_commands_serves_the_catalog_over_http() -> Result<()> {
handle.abort();
Ok(())
}
/// B4: `GET /v1/hooks` serves the hook set Runtime threads run, from the
/// engine's own loader, with credential-shaped values masked.
#[tokio::test]
async fn get_v1_hooks_serves_the_runtime_hook_set() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("hooks-route");
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let mut config = Config::default();
config.hooks = Some(crate::hooks::HooksConfig {
hooks: vec![
crate::hooks::Hook::new(
crate::hooks::HookEvent::ToolCallAfter,
"curl -H 'Authorization: Bearer sk-ant-api03-AbCdEfGhIjKlMnOpQrStUvWxYz0123456789' https://example.invalid",
)
.with_name("notify"),
],
enabled: true,
..crate::hooks::HooksConfig::default()
});
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root,
sessions_dir,
None,
false,
workspace,
TestServerOverrides {
config: Some(config),
..TestServerOverrides::default()
},
)
.await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let body: serde_json::Value = client
.get(format!("http://{addr}/v1/hooks"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body["enabled"], true);
let hooks = body["hooks"].as_array().expect("hooks array");
assert_eq!(hooks.len(), 1, "{body}");
assert_eq!(hooks[0]["name"], "notify");
assert_eq!(hooks[0]["event"], "tool_call_after");
assert_eq!(hooks[0]["source"], "global");
let command = hooks[0]["command"].as_str().expect("command");
assert!(command.starts_with("curl"), "{command}");
assert!(!command.contains("sk-ant-api03-AbCd"), "{command}");
let missing = client
.get(format!("http://{addr}/v1/hooks?thread_id=thr_missing"))
.send()
.await?;
assert_eq!(missing.status(), reqwest::StatusCode::NOT_FOUND);
handle.abort();
Ok(())
}
+111 -10
View File
@@ -626,6 +626,49 @@ impl RuntimeThreadManager {
}
}
/// `tool_call_after` (and `on_error` for a failed call) on a Runtime API
/// thread, as the TUI fires them (B4). Observer events: their output never
/// changes the result the model sees, and a full observer queue is logged,
/// not fatal to the turn.
fn fire_runtime_tool_completion_hooks(
hooks: &crate::hooks::HookExecutor,
thread_id: &str,
id: &str,
name: &str,
result: &std::result::Result<crate::tools::spec::ToolResult, crate::tools::spec::ToolError>,
) {
use crate::hooks::{HookContext, HookEvent};
let wants_after = hooks.has_hooks_for_event(HookEvent::ToolCallAfter);
let wants_error = hooks.has_hooks_for_event(HookEvent::OnError);
if !wants_after && !wants_error {
return;
}
let (text, success) = match result {
Ok(output) => (output.content.clone(), output.success),
Err(error) => (error.to_string(), false),
};
let context = || {
HookContext::new()
.with_workspace(hooks.default_working_dir().to_path_buf())
.with_session_id(thread_id)
.with_tool_name(name)
.with_tool_call_id(id)
.with_tool_result(&text, success, None)
};
if wants_after && let Err(error) = hooks.submit_observer(HookEvent::ToolCallAfter, context()) {
tracing::warn!(target: "hooks", %error, thread_id, "tool_call_after hook was not submitted");
}
if wants_error
&& !success
&& let Err(error) = hooks.submit_observer(
HookEvent::OnError,
context().with_error(&format!("tool `{name}` failed: {text}")),
)
{
tracing::warn!(target: "hooks", %error, thread_id, "on_error hook was not submitted");
}
}
fn dynamic_tool_result_timeout() -> Duration {
#[cfg(test)]
{
@@ -4615,6 +4658,10 @@ struct ActiveThreadState {
active_turn: Option<ActiveTurnState>,
route_identity: ProviderIdentity,
route_model: String,
/// The thread's hook executor (B4): global, reviewed plugin and trusted
/// project hooks for the thread's workspace. `None` only for injected
/// test engines.
hook_executor: Option<Arc<crate::hooks::HookExecutor>>,
/// Real engines client-preflight before an in-progress record is written.
/// Explicitly injected test engines own their client seam.
client_preflight_required: bool,
@@ -4746,6 +4793,10 @@ pub struct RuntimeThreadManager {
recovery_receipts: Arc<parking_lot::Mutex<HashMap<String, Vec<RecoveredTurnReceipt>>>>,
notices: Arc<parking_lot::Mutex<HashMap<String, Vec<ActiveNotice>>>>,
recovery_flush: Arc<Mutex<()>>,
/// One hook observer pool for the process. Each thread's executor is a
/// `rebind` of it, so a thread gets its own hook set and workspace without
/// spawning another dispatcher.
hook_base: Arc<std::sync::OnceLock<crate::hooks::HookExecutor>>,
#[cfg(test)]
snapshot_test_hook: Arc<parking_lot::Mutex<Option<mpsc::UnboundedSender<SnapshotTestPoint>>>>,
#[cfg(test)]
@@ -5027,6 +5078,31 @@ struct DynamicToolSettlementAck {
impl RuntimeThreadManager {
/// Helper to read the current config under RwLock.
/// The hook executor for one workspace: global hooks from `config`,
/// reviewed plugin hooks, then trusted and approved project hooks — the
/// set the TUI and `exec --hooks` build. It shares this process's observer
/// pool instead of spawning its own.
pub(crate) fn hook_executor_for_workspace(
&self,
config: &Config,
workspace: &Path,
plugins: Option<&crate::plugins::PluginRegistry>,
) -> crate::hooks::HookExecutor {
let hooks = crate::hooks::HooksConfig::load_with_project_and_plugins(
config.hooks_config(),
workspace,
plugins,
);
self.hook_base
.get_or_init(|| {
crate::hooks::HookExecutor::new(
crate::hooks::HooksConfig::default(),
workspace.to_path_buf(),
)
})
.rebind(hooks, workspace.to_path_buf())
}
pub(crate) fn read_config(&self) -> parking_lot::RwLockReadGuard<'_, Config> {
self.config.read()
}
@@ -5327,6 +5403,7 @@ impl RuntimeThreadManager {
recovery_receipts: Arc::new(parking_lot::Mutex::new(HashMap::new())),
notices: Arc::new(parking_lot::Mutex::new(HashMap::new())),
recovery_flush: Arc::new(Mutex::new(())),
hook_base: Arc::new(std::sync::OnceLock::new()),
#[cfg(test)]
snapshot_test_hook: Arc::new(parking_lot::Mutex::new(None)),
#[cfg(test)]
@@ -10729,6 +10806,13 @@ impl RuntimeThreadManager {
})
.unwrap_or(crate::tools::goal::GoalStatus::Active);
let turn_hook_executor = self
.active
.lock()
.await
.engines
.get(thread_id)
.and_then(|state| state.hook_executor.clone());
let op = Op::SendMessage (TurnSpec {
max_output_tokens,
content: prompt,
@@ -10749,7 +10833,9 @@ impl RuntimeThreadManager {
translation_enabled: false,
allowed_tools,
dynamic_tools: req.dynamic_tools,
hook_executor: None,
// The turn op re-installs the executor into the engine, so it
// must carry the thread's own, never `None`.
hook_executor: turn_hook_executor,
approval_mode: policy.permission,
verbosity,
provenance: input_source.provenance(),
@@ -11410,6 +11496,14 @@ impl RuntimeThreadManager {
.map(|registry| registry.rediscover_for_workspace(&thread.workspace))
})
.flatten();
// Hooks fire on Runtime API threads as they do in the TUI and
// `exec --hooks` (B4): the same global, reviewed-plugin and
// trusted-project set, for this thread's workspace.
let thread_hooks = Arc::new(self.hook_executor_for_workspace(
&cfg,
&thread.workspace,
thread_plugin_registry.as_deref(),
));
// Rehydrate the persisted thread goal into the engine so the
// goal loop, prompt surface, and `update_goal` tool operate on
// the durable record from the first turn. Usage and continuation
@@ -11526,7 +11620,7 @@ impl RuntimeThreadManager {
work: None,
shell_manager: Some(shell_manager),
persist_services_enabled: false,
hook_executor: None,
hook_executor: Some(Arc::clone(&thread_hooks)),
handle_store: crate::tools::handle::new_shared_handle_store(),
rlm_sessions: crate::rlm::session::new_shared_rlm_session_store(),
media_originals_dir: crate::media_originals::default_store_dir(),
@@ -11583,7 +11677,7 @@ impl RuntimeThreadManager {
allowed_tools: isolated_chat.then(Vec::new),
disallowed_tools: None,
max_tool_calls: None,
hook_executor: None,
hook_executor: Some(Arc::clone(&thread_hooks)),
locale_tag: codewhale_localization::resolve_locale(&settings.locale)
.tag()
.to_string(),
@@ -11684,6 +11778,7 @@ impl RuntimeThreadManager {
active_turn: None,
route_identity,
route_model,
hook_executor: Some(Arc::clone(&thread_hooks)),
client_preflight_required: true,
},
);
@@ -12172,14 +12267,16 @@ impl RuntimeThreadManager {
// model's `update_goal` decision (complete/blocked/paused) lands here
// before TurnComplete, so terminal settlement can mirror it into the
// durable goal record instead of continuing to spend.
let mut admitted_goal_id = {
let (mut admitted_goal_id, thread_hooks) = {
let active = self.active.lock().await;
active
.engines
.get(&thread_id)
.and_then(|state| state.active_turn.as_ref())
.filter(|turn| turn.turn_id == turn_id)
.and_then(|turn| turn.goal_id.clone())
let state = active.engines.get(&thread_id);
(
state
.and_then(|state| state.active_turn.as_ref())
.filter(|turn| turn.turn_id == turn_id)
.and_then(|turn| turn.goal_id.clone()),
state.and_then(|state| state.hook_executor.clone()),
)
};
let mut latest_goal_snapshot: Option<crate::tools::goal::GoalSnapshot> = None;
// Tool definitions of the finished turn's request surface, from the
@@ -12501,6 +12598,9 @@ impl RuntimeThreadManager {
.await?;
}
EngineEvent::ToolCallComplete { id, name, result } => {
if let Some(hooks) = thread_hooks.as_deref() {
fire_runtime_tool_completion_hooks(hooks, &thread_id, &id, &name, &result);
}
// An elevation question is over once its tool call
// completes, however it completed. Clear before the
// notify raise below: a notify call of its own settles
@@ -14105,6 +14205,7 @@ impl RuntimeThreadManager {
active_turn: None,
route_identity: route.identity,
route_model: route.model,
hook_executor: None,
client_preflight_required: false,
},
);
+63
View File
@@ -6801,6 +6801,7 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
migrated_legacy_ollama_cloud_route: false,
},
route_model: DEFAULT_TEXT_MODEL.to_string(),
hook_executor: None,
client_preflight_required: false,
},
);
@@ -6822,6 +6823,7 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
migrated_legacy_ollama_cloud_route: false,
},
route_model: DEFAULT_TEXT_MODEL.to_string(),
hook_executor: None,
client_preflight_required: false,
},
);
@@ -18275,3 +18277,64 @@ fn runtime_approvals_wait_indefinitely_unless_approval_timeout_is_set() -> Resul
);
Ok(())
}
/// B4: a Runtime thread's tool completion fires `tool_call_after`, and a
/// failed call also fires `on_error`, through the thread's executor.
#[cfg(unix)]
#[tokio::test]
async fn runtime_tool_completion_fires_after_and_error_hooks() -> Result<()> {
use crate::hooks::{Hook, HookEvent, HooksConfig};
let dir = tempfile::tempdir()?;
let log = dir.path().join("hooks.log");
let append = |label: &str| {
format!(
"printf '%s %s %s\\n' {label} \"$CODEWHALE_TOOL_CALL_ID\" \"$DEEPSEEK_TOOL_SUCCESS\" >> {}",
log.display()
)
};
let manager = test_manager(test_runtime_dir())?;
let mut config = Config::default();
config.hooks = Some(HooksConfig {
hooks: vec![
Hook::new(HookEvent::ToolCallAfter, &append("after")),
Hook::new(HookEvent::OnError, &append("error")),
],
enabled: true,
..HooksConfig::default()
});
let hooks = manager.hook_executor_for_workspace(&config, dir.path(), None);
fire_runtime_tool_completion_hooks(
&hooks,
"thr_1",
"call-ok",
"exec_command",
&Ok(crate::tools::spec::ToolResult::success("fine")),
);
fire_runtime_tool_completion_hooks(
&hooks,
"thr_1",
"call-bad",
"exec_command",
&Ok(crate::tools::spec::ToolResult::error("exit 1")),
);
let deadline = Instant::now() + Duration::from_secs(10);
let text = loop {
let text = std::fs::read_to_string(&log).unwrap_or_default();
if text.lines().count() >= 3 || Instant::now() >= deadline {
break text;
}
sleep(Duration::from_millis(20)).await;
};
let mut lines: Vec<&str> = text.lines().collect();
lines.sort_unstable();
assert_eq!(
lines,
vec![
"after call-bad false",
"after call-ok true",
"error call-bad false"
],
"{text}"
);
Ok(())
}
+3 -2
View File
@@ -13,13 +13,14 @@ contract.
## Scope
Hooks are a **TUI runtime feature**. Every firing point lives in the
interactive TUI and in the engine turn loop it drives.
Hooks fire in the interactive TUI and in the engine turn loop, which the
Runtime API threads behind the desktop app and web drive as well.
| Surface | Fires hooks |
| --- | --- |
| `codewhale` / `codew` interactive TUI | yes |
| `codewhale exec` (headless one-shot) | opt-in: `--hooks` fires `tool_call_before` and `shell_env` |
| Runtime API threads (desktop app, web) | yes: `tool_call_before`, `shell_env`, `tool_call_after`, `on_error`; `GET /v1/hooks` lists the set |
| the `codewhale` CLI dispatcher and its subcommands | no |
| app-server / ACP | no |
| the `workflow` tool and sub-agent *internals* | no — but the TUI fires `subagent_spawn` / `subagent_complete` around them |
+14
View File
@@ -1320,6 +1320,20 @@ also how a client sees model-spawned work.
respect: a `binding: "host"` row is never submitted as a model prompt, and
a user command shadowing a builtin name wins that spelling.
**Hooks**
- `GET /v1/hooks[?thread_id=...]` — `{workspace, enabled, hooks: [...],
problems: [...]}`: the hook set Runtime API threads run for that workspace
(the server workspace, or the named thread's). Per entry: `name`, `event`,
`command` (credential-shaped values masked), `background`, `timeout_secs`,
and `source` (`global` user config, `plugin` reviewed plugin, `project`
trusted and approved `.codewhale/hooks.toml`). `problems` lists hooks
rejected or warned about at load, one line each.
Every Runtime thread builds its engine with this set: `tool_call_before`
can deny a call, `shell_env` applies to shell tools, and `tool_call_after`
and `on_error` (for a failed tool) fire as observers, as they do in the TUI.
Clients read this route instead of keeping a hook table of their own.
**Context** (per-thread context pressure, APPS-90)
- `GET /v1/threads/{id}/context` — `input_tokens` (the conservative live
estimate the visible meter uses), `billed_input_tokens` (last