Skip to content

feat(hooks): add UnixSocketHookSink for real-time event streaming#2333

Open
lihuan215 wants to merge 1 commit into
Hmbown:mainfrom
lihuan215:feature/unix-socket-hook-sink
Open

feat(hooks): add UnixSocketHookSink for real-time event streaming#2333
lihuan215 wants to merge 1 commit into
Hmbown:mainfrom
lihuan215:feature/unix-socket-hook-sink

Conversation

@lihuan215
Copy link
Copy Markdown

@lihuan215 lihuan215 commented May 29, 2026

Summary

Adds a new UnixSocketHookSink to the hooks crate, enabling real-time lifecycle event streaming over a Unix domain socket. This extends the hook system beyond stdout / JSONL file / webhook to support low-latency local IPC — useful for external monitoring dashboards, notification systems, and tool integration without polling files.

Motivation

The current hook sinks all have trade-offs:

Sink Latency Use Case
StdoutHookSink real-time mixed with other output, hard to parse reliably
JsonlHookSink real-time (per-event fsync) durable audit log, but requires file polling for consumers
WebhookHookSink network round-trip remote monitoring, adds HTTP overhead

None of them offer local, push-based, near-zero-overhead event delivery. A Unix socket fills this gap — connect, receive JSON lines, disconnect. No filesystem polling. No HTTP server. No mixed stdout.

What this PR does

  1. crates/hooks/src/lib.rs: Adds UnixSocketHookSink implementing HookSink

    • Connects to a Unix domain socket on each emit (stateless)
    • Sends {"at": "...", "event": {...}}\n JSON lines
    • Silently skips when the listener is not running — hooks are observability, not control flow
    • #[cfg(unix)]-guarded; no-op on non-Unix platforms
    • Includes two tests: skip-when-absent, send-to-listener
  2. crates/config/src/lib.rs: Adds [hooks] config section (HooksToml)

    [hooks]
    sink = "unix-socket"
    socket_path = "/tmp/codewhale-island.sock"
    
  3. crates/app-server/src/lib.rs: Wires up the sink based on config

    • When [hooks] sink = "unix-socket", adds UnixSocketHookSink to the dispatcher
    • Defaults socket path to /tmp/codewhale-island.sock

Design Decisions

  • Connect-per-event: Simpler than persistent connection management. For a local Unix socket, connect overhead is negligible (< 0.1ms).
  • Silent skip on failure: If the listener is not running, events are dropped rather than erroring. This keeps the hook system non-critical.
  • Reuses existing wire format: Same {"at": "...", "event": {...}} envelope as JsonlHookSink and WebhookHookSink.
  • Config-driven: Zero code changes needed to enable — just add [hooks] to config.toml.

Future Possibilities

This sink opens the door to:

  • External approval interception (listener responds to ApprovalLifecycle events)
  • Real-time cost/token dashboards
  • CI/CD pipeline integration (stream tool execution for audit)
  • Desktop notification bridges
  • Agent-to-agent communication patterns

Greptile Summary

This PR adds a UnixSocketHookSink that delivers lifecycle events as JSON lines over a Unix domain socket, wired up via a new [hooks] TOML config section. The sink uses a connect-per-event model and silently drops events when no listener is present.

  • crates/hooks/src/lib.rs: New UnixSocketHookSink with #[cfg(unix)]-guarded emit; connection errors are silently swallowed but write errors (e.g. broken-pipe when a listener crashes mid-write) are propagated, inconsistent with the stated best-effort design.
  • crates/config/src/lib.rs: Adds HooksToml and a hooks field to ConfigToml; jsonl_path and webhook_url fields are declared but never consumed by app-server.
  • crates/app-server/src/lib.rs: Reads the new config and conditionally adds the sink; only the exact string "unix-socket" is recognised — all other sink values are silently ignored.

Confidence Score: 3/5

The new sink code is safe to run but has several open correctness issues across all three changed files that should be resolved before merging.

The write-error propagation in UnixSocketHookSink is inconsistent with the silent-skip contract. The test helper unique_temp_dir returns a non-existent directory path, causing UnixListener::bind to fail with ENOENT. The config struct declares jsonl_path and webhook_url that are parsed but never acted on, and any sink value other than "unix-socket" is silently ignored in app-server. Together these mean the feature doesn't fully behave as documented and the new socket test is broken on most environments.

All three changed files need attention: crates/hooks/src/lib.rs for the error-handling inconsistency and test directory bug, crates/config/src/lib.rs for the unused declared fields, and crates/app-server/src/lib.rs for the silent-ignore behaviour on unrecognised sink values.

Important Files Changed

Filename Overview
crates/hooks/src/lib.rs Adds UnixSocketHookSink with connect-per-event design and #[cfg(unix)]-guarded emit; write errors are propagated while connection errors are silently swallowed (inconsistent), and the new test uses unique_temp_dir whose directory is never created before UnixListener::bind.
crates/config/src/lib.rs Adds HooksToml struct and hooks field to ConfigToml; serde attribute placement keeps #[serde(flatten)] correctly on extras, but jsonl_path and webhook_url fields are declared yet never consumed by app-server.
crates/app-server/src/lib.rs Wires UnixSocketHookSink from config; only the "unix-socket" string is handled — any other configured value is silently ignored with no warning to the user.

Sequence Diagram

sequenceDiagram
    participant Engine as Engine (Runtime)
    participant Dispatcher as HookDispatcher
    participant Sink as UnixSocketHookSink
    participant OS as OS / Unix Socket
    participant Listener as External Listener

    Engine->>Dispatcher: emit(HookEvent)
    loop for each sink
        Dispatcher->>Sink: emit(event)
        Sink->>OS: UnixStream::connect(path)
        alt Listener not running
            OS-->>Sink: Err (ENOENT / ECONNREFUSED)
            Sink-->>Dispatcher: Ok(()) [silent skip]
        else Listener running
            OS-->>Sink: Ok(stream)
            Sink->>Sink: serde_json::to_string(payload)
            Sink->>OS: write_all(json_line + "\n")
            alt Write succeeds
                OS->>Listener: JSON line delivered
                Sink-->>Dispatcher: Ok(())
            else Write fails (broken pipe)
                OS-->>Sink: Err
                Sink-->>Dispatcher: Err [propagated — inconsistent with silent-skip design]
            end
            Sink->>OS: stream dropped → FIN
        end
        Dispatcher->>Dispatcher: log error and continue (if Err)
    end
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "feat(hooks): add UnixSocketHookSink for ..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a UnixSocketHookSink to send lifecycle events over a Unix domain socket for external monitoring, along with configuration support via a new [hooks] table. While the feature is useful, several issues must be addressed: a critical deserialization bug caused by #[serde(flatten)] that breaks configuration parsing, a security risk from using a default socket path in /tmp, performance overhead from reconnecting to the socket on every event emission, and a test failure due to a missing parent directory creation before binding.

Comment thread crates/config/src/lib.rs Outdated
Comment on lines +241 to +245
#[serde(flatten)]
/// Hook sink configuration. When absent, the runtime defaults to
/// stdout + JSONL sinks only (current behaviour).
#[serde(default)]
pub hooks: Option<HooksToml>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

Using #[serde(flatten)] on the hooks field causes Serde to look for the HooksToml fields (like sink, socket_path, etc.) at the root level of the configuration instead of nesting them under a [hooks] table. This completely breaks parsing when the configuration is written as [hooks] in config.toml (as documented in the PR description), resulting in config.hooks being None and the sink never being initialized. Removing #[serde(flatten)] resolves this issue.

Suggested change
#[serde(flatten)]
/// Hook sink configuration. When absent, the runtime defaults to
/// stdout + JSONL sinks only (current behaviour).
#[serde(default)]
pub hooks: Option<HooksToml>,
/// Hook sink configuration. When absent, the runtime defaults to
/// stdout + JSONL sinks only (current behaviour).
#[serde(default)]
pub hooks: Option<HooksToml>,

Comment on lines +321 to +322
let socket_path = hooks_cfg.socket_path.clone()
.unwrap_or_else(|| "/tmp/codewhale-island.sock".to_string());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Using a hardcoded socket path in /tmp (which is world-writable on Unix systems) poses a security risk of Socket Hijacking. A malicious local user could pre-create /tmp/codewhale-island.sock with permissive permissions or listen on it, allowing them to intercept sensitive lifecycle events (which contain LLM prompts, responses, and tool execution payloads). Placing the default socket path inside the user's home directory (e.g., ~/.codewhale/) ensures appropriate restricted permissions.

            let socket_path = hooks_cfg.socket_path.clone()
                .unwrap_or_else(|| {
                    codewhale_config::codewhale_home()
                        .map(|p| p.join("codewhale-island.sock").to_string_lossy().into_owned())
                        .unwrap_or_else(|_| "/tmp/codewhale-island.sock".to_string())
                });

Comment thread crates/hooks/src/lib.rs
Comment on lines +179 to +182
let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
Ok(s) => s,
Err(_) => return Ok(()), // listener not running, skip silently
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Connecting to the Unix socket on every single emit call introduces significant performance overhead, especially for high-frequency events like ResponseDelta (which is triggered for every single token generated by the LLM). This can lead to high CPU usage and potential socket descriptor exhaustion. Consider caching/reusing the connection using a tokio::sync::Mutex<Option<tokio::net::UnixStream>> or utilizing a channel-based background worker to handle persistent connection management.

Comment thread crates/hooks/src/lib.rs
Comment on lines +328 to +331
let socket_path = unique_temp_dir("unix_sink").join("test.sock");
let _ = std::fs::remove_file(&socket_path);

let listener = UnixListener::bind(&socket_path).expect("bind");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The test unix_socket_sink_sends_event_to_listener will fail because unique_temp_dir only generates a path but does not actually create the directory. UnixListener::bind expects the parent directory of the socket path to already exist, otherwise it returns a NotFound error. Creating the parent directory before binding resolves this issue.

Suggested change
let socket_path = unique_temp_dir("unix_sink").join("test.sock");
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path).expect("bind");
let socket_dir = unique_temp_dir("unix_sink");
std::fs::create_dir_all(&socket_dir).expect("create socket dir");
let socket_path = socket_dir.join("test.sock");
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path).expect("bind");

Comment thread crates/config/src/lib.rs Outdated
Comment thread crates/hooks/src/lib.rs
Comment thread crates/config/src/lib.rs
Comment thread crates/app-server/src/lib.rs
@lihuan215 lihuan215 force-pushed the feature/unix-socket-hook-sink branch from 82ad6df to dacd380 Compare May 29, 2026 00:32
@lihuan215 lihuan215 force-pushed the feature/unix-socket-hook-sink branch from dacd380 to 30a09b4 Compare May 29, 2026 00:33
@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented May 31, 2026

wow - thank you. will work on getting this in. super appreciative

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants