mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
ed241c3a7f
commit
1700c556eb
7 files changed
+349
-12
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user