Skip to content

hooks: every tool call pays ~280 ms of binary startup — proposal: thin hook client + untracked-cwd fast path #479

Description

@pbednarcik

The cost

Agent harnesses fire PreToolUse on every tool call, and the hook command is the full gortex binary. Measured over one real working session: 223 PreToolUse + 14 PostToolUse fires ≈ 74 seconds of serialized wait — PreToolUse hooks block the tool call they precede. This is the mechanical signature of "the agent feels laggy" with Gortex hooks installed.

Where the time goes (Windows 11, warm cache; every number below includes ~31 ms of shell-spawn measurement overhead — compare rows, not absolutes):

Invocation Binary size Wall time
gortex hook fed a payload that fails JSON parse on line 1 (no daemon dial, no work) ~380 MB 278–284 ms
Scratch binary importing only internal/hooks, same payload ~291 MB 76–103 ms
Hello-world Go binary (spawn floor for this method) 2.4 MB ~31 ms

So ~250 ms of the per-call cost is pure process startup of the everything-binary — before the hook logic runs at all. The worst case observed in that session was 3.0 s (a cold start eating the entire hook timeout budget).

Why the hook can't currently build small

internal/hooks has modest direct imports, but its daemon transport is internal/daemon — a package that is client and server in one: 72 internal deps including all 31 tree-sitter grammars. internal/mcp (used for wire types) is heavier still (126 deps). Anything that dials the daemon today links the whole engine.

The wire protocol itself needs none of that: internal/daemon/client.go and proto.go are already pure stdlib (unix socket, newline-delimited JSON handshake + control frames). They're heavy only by package association.

The second problem: untracked-cwd sessions pay full price for wrong advice

In a session whose cwd is not under any tracked root, the hook still costs the full ~280 ms per call — and the enrichment it emits mandates MCP tools that answer repository not tracked in that session. Deny plus an unusable alternative is the worst combination; silence would be strictly better and nearly free.

Proposal (sketched, in adoptable phases)

  1. Extract a leaf transport package — move client.go / proto.go / socket-path discovery out of internal/daemon into e.g. internal/daemon/client with zero engine imports (mechanical; the files already qualify). Server keeps re-exporting for compatibility.
  2. A hook_dispatch control RPC — takes the raw hook event JSON + agent-protocol name, returns the response JSON; internal/hooks' dispatch logic moves behind the daemon boundary, where the graph, config, and telemetry already live. gortex hook keeps working by calling the same RPC.
  3. cmd/gortex-hook — a thin main: read stdin → tracked-roots fast path → hook_dispatch → print. Untracked cwd exits silently after one local config read, no dial.

Proof-of-concept on my fork: poc/thin-hook-client — a single-file stdlib-only client (hand-inlined copy of the transport essentials, which Phase 1 would extract instead). It builds at ~4 MB; the untracked fast path completes in ~47 ms wall (~16 ms process time), and dial + handshake + a status round trip lands ~125 ms wall against a warm daemon. Two findings from measuring the tracked path that inform Phase 2: the handshake currently does full per-session bookkeeping (first dial from a fresh client cost ~1 s), and status is a heavyweight stand-in — a purpose-built hook_dispatch behind a lightweight handshake mode (no session state for one-shot clients) should land well under 50 ms end to end.

Alternatives considered

  • Long-lived hook helper (one resident process relaying events): even lower per-call cost, but adds lifecycle management the thin client avoids; could be a later optimization behind the same RPC.
  • Trimming the main binary's init so gortex hook itself starts fast: the grammars are CGO-linked and the import graph is the product — this looked like a much larger fight for a worse ceiling.

Happy to implement whichever shape you prefer — the phases are independent, and Phase 1 alone (the leaf transport package) already unblocks any future slim consumer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions