feat(agent): Claude Code-compatible project hooks system (#114)#117
Conversation
Adds project-local shell hooks that fire at pre-tool and post-tool boundaries, configured via .tenex-hooks.json in the agent workspace. Hooks receive JSON context on stdin and can block a tool call (non-zero exit on pre-tool) or inject context into the next LLM call (stdout on exit 0). This enables the proactive-context pattern and any generic Claude Code-compatible hook command. - New `project_hooks` module owns config loading (NotFound -> default, malformed -> hard error, matching the tenex-mcp pattern), hook spawning, the stdin protocol, and a 30s per-hook timeout. - `EmitHook` gains an optional `ProjectHooksRunner` and an injection buffer: `on_tool_call` fires pre-tool hooks (blocking before the supervisor check), `on_tool_result` fires post-tool hooks, and `drain_hook_injections` hands the collected stdout to the turn loop. - The turn loop drains injections after `on_tool_result` and folds them into the persisted tool record so the model reads them on its next turn. Pre-tool block reasons surface from the hook's stderr. - E2E probe scenario `hooks-pre-tool` exercises a workspace hook that blocks the shell tool; the verdict confirms the block reason reaches the model and the command never executed. Closes #114 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Code ReviewOverviewThis PR implements a Claude Code-compatible project hooks system: shell commands fire at Bug: Orphaned child processes on timeout
Err(_) => HookRun::TimedOut, // child is leakedOver a long session with slow hooks this accumulates orphans. Fix with tokio::select! {
result = child.wait_with_output() => {
match result {
Ok(output) => { /* existing handling */ }
Err(e) => HookRun::SpawnFailed(format!("awaiting hook: {e}")),
}
}
_ = tokio::time::sleep(HOOK_TIMEOUT) => {
let _ = child.kill().await;
HookRun::TimedOut
}
}Security concern: spawn failures silently bypass pre-tool gatesThe config parser is deliberately strict ("malformed config is fatal"), but a spawn failure at runtime is treated as non-blocking: HookRun::SpawnFailed(e) => {
eprintln!("[tenex-agent] warn: pre-tool hook '{}' failed to run: {e}; continuing", ...);
// falls through to Continue
}If an operator configures a security-blocking hook (e.g. Code style:
|
| Must fix | Orphaned child on timeout |
| Should fix | Spawn failure silently bypasses pre-tool gate |
| Style | last_mut() over records_before; multi-line comments |
| Low priority | Post-tool stderr mention in warning message |
Addresses review feedback on #117 (project hooks system). - Kill the timed-out hook child instead of detaching it: set kill_on_drop(true) so dropping the wait future on timeout sends SIGKILL, removing the orphaned-process bug in run_hook. - Pre-tool spawn failures now block the tool call (a missing or non-executable hook binary is a misconfiguration, not flakiness). Post-tool spawn failures stay warnings since the tool already ran. - Use records.last_mut() instead of a records_before index when folding hook injections into the tool record. - Collapse multi-line doc/comment blocks to single WHY lines per the project comment policy. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…ios pass (#120) Four fixes to the e2e probe scenarios added in #116 and #117: - file-modification-tracking: pm agent must be 'generalist' (not orchestrator) so workspace fs tools are available; orchestrators are workspace-restricted. - file-modification-tracking: cassette reordered so the more-specific second-run entry (with fileModificationSecondRequest in history) is checked before the first-run entry, preventing false matches on the shared conversation history. - file-modification-tracking: scenario driver switches from waitForObservedEvent (relay push, blocked by relay ACL deferral) to waitForStoredMessage (DB poll). - hooks-pre-tool: same relay ACL fix — final completion now detected via DB. - verdicts: requestDebug uses Rust {:?} escaping; quote checks updated to match the escaped form (type=\"file-modifications\"). Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
Implements issue #114: a Claude Code-compatible hooks system. Project-local shell commands fire at
pre-toolandpost-toolboundaries, configured via.tenex-hooks.jsonin the agent's workspace directory. Hooks receive JSON context on stdin and can block a tool call (non-zero exit onpre-tool) or inject context into the next LLM call (stdout on exit 0). This enables the proactive-context pattern and any generic Claude Code-compatible hook command.What's included
New
project_hooksmodule (crates/tenex-agent/src/project_hooks.rs)ProjectHooksConfig::load—NotFound→ empty config, malformed JSON → hard error (mirrors thetenex-mcpproject-config pattern). A broken hook file must not silently disable the gating an operator configured.ProjectHooksRunner— spawns hooks with the workspace as cwd, writes the JSON stdin payload, enforces a 30s per-hook timeout (pre-tool timeout → continue; post-tool timeout → ignore), and forwards hook stderr to the agent's stderr.{"event":"pre-tool","tool":"…","args":{…}}/{"event":"post-tool",…,"result":"…"}.argsis embedded as parsed JSON.EmitHookintegration (hook.rs)ProjectHooksRunnerplus an injection buffer.on_tool_callfires pre-tool hooks before the supervisor check (blocking viaToolCallHookAction::skip);on_tool_resultfires post-tool hooks;drain_hook_injectionshands collected stdout to the turn loop.Turn-loop wiring (
turn_loop/step/tools.rs)on_tool_resultand folds them into the persistedToolCallRecordfor that call, so the model reads the injected context on its next turn. (Draining inrecording.rswould have mis-ordered post-tool injections, since the record is taken beforeon_tool_resultfires.)Bootstrap wiring —
stages::load_project_hooksloads the workspace config;assembly::init_supervisor_and_hookconstructs the runner and threads it intoEmitHook::new.E2E probe scenario
hooks-pre-tool— seeds a workspace.tenex-hooks.jsonwhose hook blocks theshelltool withhook-blockedon stderr. The verdict confirms the block reason reaches the model as the tool result and the command never executed. (A blocked tool is skipped before any tool-use event is published, so the verdict keys on the request record, not a nostr tool event.)Tests
project_hooks(config loading, block/inject semantics, stdin protocol, stderr block reason, fallback).append_to_record_result).cargo test -p tenex-agent— all pass.The probe harness cannot run in this environment (missing
nostr-tools+ local relay/runtime), so the scenario is validated bybun buildof the full probe graph and pattern-matched against existing scenarios (file-modification-tracking,todo-stop).Closes #114
🤖 Generated with Claude Code