Skip to content

Commit 08fe14c

Browse files
xinhuaguXinhua Gu
andauthored
feat: implement command hook system (#32) (#41)
* feat: implement command hook system (#32) Add extensibility hook system matching Claude Code's design. Hooks let users run shell commands at tool lifecycle points (PreToolUse, PostToolUse, PostToolUseFailure) to enforce policies, audit actions, or modify inputs. - 3 core abstractions: HookEvent (sealed), HookResult (sealed), HookExecutor - HookConfig/HookMatcher/HookRegistry for config-driven hook resolution - CommandHookExecutor: ProcessBuilder with JSON stdin/stdout, exit code semantics (0=proceed, 2=block, other=non-blocking error), timeout - PreToolUse hooks run before permission check; can block or modify input - PostToolUse/PostToolUseFailure hooks fire async on virtual threads - Config loaded from ~/.aceclaw/config.json and project config (appending) - 24 new tests across 3 test classes (unit + E2E integration) * fix: read stdout/stderr concurrently to prevent pipe deadlock on Linux Process pipe buffers are finite (~64KB on Linux). If the hook command writes more than the buffer capacity before we start reading, it blocks on write while we block on waitFor() — a classic deadlock. Fix by draining stdout and stderr on virtual threads concurrently with waitFor. * fix: start stdout/stderr readers before stdin write to prevent data loss On fast CI runners, the hook process may exit before stdin write completes. If the process doesn't read stdin, the write throws broken pipe IOException which previously aborted the entire executeOne() method — stdout/stderr reader threads were never started, so the hook's output was lost. Fix: start reader threads FIRST, then write stdin with a separate try-catch so broken pipe is non-fatal. This ensures we always capture the process output regardless of stdin write success. * refactor: address CodeRabbit review feedback on PR #41 - Convert HookMatcherFormat/HookConfigFormat from mutable classes to records - Use session's projectPath for hook cwd instead of daemon's workingDir - Replace Thread.sleep with bounded polling in HookIntegrationTest - Add clarifying comment on exit-0 deny test contract * fix: guard against null in hook merge and PostToolUseFailure - AceClawConfig: skip null/empty hook lists during config merge to prevent NPE - StreamingAgentHandler: ensure non-null error string for PostToolUseFailure so firePostHookAsync dispatches the correct event type --------- Co-authored-by: Xinhua Gu <xinhua.gu@arz.at>
1 parent fa976fe commit 08fe14c

13 files changed

Lines changed: 1746 additions & 11 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package dev.aceclaw.core.agent;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
5+
/**
6+
* Events that trigger hook execution at different points in the tool lifecycle.
7+
*
8+
* <p>Three event types correspond to Claude Code's hook events:
9+
* <ul>
10+
* <li>{@link PreToolUse} — fired before tool execution (blocking; can modify input or block)</li>
11+
* <li>{@link PostToolUse} — fired after successful tool execution (non-blocking)</li>
12+
* <li>{@link PostToolUseFailure} — fired after failed tool execution (non-blocking)</li>
13+
* </ul>
14+
*/
15+
public sealed interface HookEvent {
16+
17+
/** The session ID of the current agent session. */
18+
String sessionId();
19+
20+
/** The current working directory. */
21+
String cwd();
22+
23+
/** The name of the tool being invoked. */
24+
String toolName();
25+
26+
/** The tool input as a JSON object. */
27+
JsonNode toolInput();
28+
29+
/**
30+
* The event type name used in config matching and JSON serialization
31+
* (e.g. "PreToolUse", "PostToolUse", "PostToolUseFailure").
32+
*/
33+
String eventName();
34+
35+
/**
36+
* Fired before a tool is executed. The hook can block execution or modify the input.
37+
*/
38+
record PreToolUse(String sessionId, String cwd, String toolName, JsonNode toolInput)
39+
implements HookEvent {
40+
@Override
41+
public String eventName() { return "PreToolUse"; }
42+
}
43+
44+
/**
45+
* Fired after a tool executes successfully. Non-blocking — hooks run but cannot alter the result.
46+
*
47+
* @param toolOutput the textual output of the tool execution
48+
*/
49+
record PostToolUse(String sessionId, String cwd, String toolName, JsonNode toolInput,
50+
String toolOutput) implements HookEvent {
51+
@Override
52+
public String eventName() { return "PostToolUse"; }
53+
}
54+
55+
/**
56+
* Fired after a tool execution fails. Non-blocking — hooks run for auditing/logging.
57+
*
58+
* @param error the error message from the failed execution
59+
*/
60+
record PostToolUseFailure(String sessionId, String cwd, String toolName, JsonNode toolInput,
61+
String error) implements HookEvent {
62+
@Override
63+
public String eventName() { return "PostToolUseFailure"; }
64+
}
65+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package dev.aceclaw.core.agent;
2+
3+
/**
4+
* Executes hooks for a given hook event.
5+
*
6+
* <p>Implementations run one or more hook commands (shell scripts, binaries, etc.)
7+
* and return the aggregate result. For {@link HookEvent.PreToolUse} events,
8+
* the first {@link HookResult.Block} stops further hook execution.
9+
* For post-execution events, all hooks always run.
10+
*/
11+
@FunctionalInterface
12+
public interface HookExecutor {
13+
14+
/**
15+
* Executes all matching hooks for the given event.
16+
*
17+
* @param event the hook event describing the tool invocation context
18+
* @return the result of hook execution
19+
*/
20+
HookResult execute(HookEvent event);
21+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package dev.aceclaw.core.agent;
2+
3+
import com.fasterxml.jackson.databind.JsonNode;
4+
5+
/**
6+
* Result of a hook execution, determining whether tool execution should proceed.
7+
*
8+
* <p>Exit code semantics (matching Claude Code):
9+
* <ul>
10+
* <li>0 → {@link Proceed} — allow execution, optionally with modified input</li>
11+
* <li>2 → {@link Block} — block execution with a reason</li>
12+
* <li>other → {@link Error} — non-blocking error, log and continue</li>
13+
* </ul>
14+
*/
15+
public sealed interface HookResult {
16+
17+
/**
18+
* Hook permits execution to proceed.
19+
*
20+
* @param stdout raw stdout from the hook process
21+
* @param updatedInput optional modified tool input (null = no modification)
22+
* @param additionalContext optional context string to append to tool description
23+
*/
24+
record Proceed(String stdout, JsonNode updatedInput, String additionalContext) implements HookResult {
25+
/** Convenience constructor for a simple proceed with no modifications. */
26+
public Proceed() { this(null, null, null); }
27+
}
28+
29+
/**
30+
* Hook blocks execution.
31+
*
32+
* @param reason human-readable reason for blocking
33+
*/
34+
record Block(String reason) implements HookResult {}
35+
36+
/**
37+
* Hook encountered a non-blocking error (exit code != 0 and != 2).
38+
* Execution should continue; the error is logged.
39+
*
40+
* @param exitCode the process exit code
41+
* @param message stderr or error description
42+
*/
43+
record Error(int exitCode, String message) implements HookResult {}
44+
}

aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawConfig.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
import java.io.IOException;
99
import java.nio.file.Files;
1010
import java.nio.file.Path;
11+
import java.util.ArrayList;
12+
import java.util.HashMap;
13+
import java.util.List;
1114
import java.util.Map;
1215

1316
/**
@@ -62,6 +65,7 @@ public final class AceClawConfig {
6265
private String defaultProfile;
6366
private Map<String, ConfigFileFormat> profiles;
6467
private Map<String, String> providerModels;
68+
private Map<String, List<HookMatcherFormat>> hooks;
6569

6670
private AceClawConfig() {
6771
this.provider = "anthropic";
@@ -260,6 +264,14 @@ public String permissionMode() {
260264
return permissionMode;
261265
}
262266

267+
/**
268+
* Returns the hooks configuration map (event name to list of hook matchers).
269+
* Returns null if no hooks are configured.
270+
*/
271+
public Map<String, List<HookMatcherFormat>> hooks() {
272+
return hooks;
273+
}
274+
263275
/**
264276
* Returns whether an API key is configured.
265277
*/
@@ -364,6 +376,21 @@ private void mergeFromFile(Path configFile) {
364376
this.providerModels.putAll(fileConfig.providerModels);
365377
}
366378

379+
// Hooks: project config appends to global config per event type
380+
if (fileConfig.hooks != null && !fileConfig.hooks.isEmpty()) {
381+
if (this.hooks == null) {
382+
this.hooks = new HashMap<>();
383+
}
384+
for (var hookEntry : fileConfig.hooks.entrySet()) {
385+
var hooksForEvent = hookEntry.getValue();
386+
if (hooksForEvent == null || hooksForEvent.isEmpty()) {
387+
continue;
388+
}
389+
this.hooks.computeIfAbsent(hookEntry.getKey(), _ -> new ArrayList<>())
390+
.addAll(hooksForEvent);
391+
}
392+
}
393+
367394
log.debug("Loaded config from {}", configFile);
368395
} catch (IOException e) {
369396
log.warn("Failed to read config file {}: {}", configFile, e.getMessage());
@@ -425,5 +452,20 @@ static final class ConfigFileFormat {
425452
public String defaultProfile;
426453
public Map<String, ConfigFileFormat> profiles;
427454
public Map<String, String> providerModels;
455+
public Map<String, List<HookMatcherFormat>> hooks;
428456
}
457+
458+
/**
459+
* JSON structure for a hook matcher entry in config.
460+
* <pre>{ "matcher": "bash", "hooks": [{ "type": "command", "command": "...", "timeout": 30 }] }</pre>
461+
*/
462+
@JsonIgnoreProperties(ignoreUnknown = true)
463+
public record HookMatcherFormat(String matcher, List<HookConfigFormat> hooks) {}
464+
465+
/**
466+
* JSON structure for a single hook config entry.
467+
* <pre>{ "type": "command", "command": "echo ok", "timeout": 60 }</pre>
468+
*/
469+
@JsonIgnoreProperties(ignoreUnknown = true)
470+
public record HookConfigFormat(String type, String command, int timeout) {}
429471
}

aceclaw-daemon/src/main/java/dev/aceclaw/daemon/AceClawDaemon.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,19 @@ private void wireAgentHandler(Path workingDir) {
306306
agentHandler.setDailyJournal(journal);
307307
}
308308

309-
// 9. Self-improvement engine (post-turn learning analysis + strategy refinement)
309+
// 9. Hook system (command hooks at tool lifecycle points)
310+
var hookRegistry = HookRegistry.load(config.hooks());
311+
if (!hookRegistry.isEmpty()) {
312+
var hookExecutor = new CommandHookExecutor(hookRegistry, objectMapper, workingDir);
313+
agentHandler.setHookExecutor(hookExecutor);
314+
log.info("Hook system wired: {} matchers across {} event types",
315+
hookRegistry.size(),
316+
(hookRegistry.hasHooksFor("PreToolUse") ? 1 : 0) +
317+
(hookRegistry.hasHooksFor("PostToolUse") ? 1 : 0) +
318+
(hookRegistry.hasHooksFor("PostToolUseFailure") ? 1 : 0));
319+
}
320+
321+
// 10. Self-improvement engine (post-turn learning analysis + strategy refinement)
310322
if (memoryStore != null) {
311323
var errorDetector = new ErrorDetector(memoryStore);
312324
var patternDetector = new PatternDetector(memoryStore);

0 commit comments

Comments
 (0)