Skip to content

Proposal: a small native hook helper for CLIs that can only run hook commands (Codex, Qwen Code, Kimi, Grok, Hermes) #91

Description

@BIackFIame

Problem

Every lifecycle hook and every decision hook (PreToolUse) of a command-only CLI starts hook-helper.mjs or permission-gate.mjs through the Electron binary with ELECTRON_RUN_AS_NODE=1. The helper's own work takes about 55 µs. Starting and stopping the Electron-as-Node process takes about 97 ms and costs about 96 ms of CPU, on every call. Codex fires PostToolUse after every tool, and with decision hooks PreToolUse fires before every shell or file call. A turn with 40 tool calls therefore spends 4–8 s of wall time and just as much CPU starting helpers.

Claude Code can call a URL instead of a process, and the Claude HTTP hooks PR does that for its lifecycle events. Codex, Qwen Code, Kimi, Grok and Hermes can only run a command, so for them a process start is unavoidable. The question is how cheap that process can be.

Measurements (Apple M1 Max, macOS 26.6.2)

Every variant does the same work: read stdin JSON up to 512 KB, send one NDJSON line to the Unix socket, wait for the ack, exit 0. Each variant's output was compared byte for byte with hook-helper.mjs on 35 edge cases: lone surrogates, invalid UTF-8, an emoji at the 4096 boundary, nesting 5000 levels deep, input over 512 KB, duplicate keys and so on. Each row is 400–500 runs after warm-up, times in ms. The machine was loaded (load average 5–13), so medians are inflated by 10–30 %.

Helper median p95 min CPU max RSS size parity
C (libc, -O2) 2.11–2.15 2.53–2.60 1.63 1.5 1.4 MB 34 KB 35/35
Go port (syscall only; no encoding/json, regexp or net) 2.82–3.10 3.46–3.62 2.29 2.2 3.5 MB 1.2 MB 35/35
Go prototype (-s -w, CGO_ENABLED=0) 3.35–3.56 3.89–4.52 2.79 2.7 5.1 MB 2.7 MB 31/35
Rust 1.46 (x86_64 under Rosetta; measured, not representative) 11.2 13.2 9.5 8.5 3.3 MB 217 KB 35/35
JS via node 23 62.8 68.0 60.9 62 44 MB – reference
Today: JS via Electron-as-Node 96.7 110.5 91.7 96 62 MB – reference

The lower bounds of a process start, for comparison: /usr/bin/true takes 1.27 ms; a C "hello" with dyld and libSystem takes 1.94 ms. The C helper's own logic takes about 55 µs, so 97 % of its 2.1 ms is creating and tearing down the process. A Rust build with a current arm64 toolchain should land near C, since its std start-up is minimal, but that was not measured.

End to end, including how the CLI runs the command

Path median p95 CPU per call
Today: sh -c + Electron-as-Node ~100 ~115 ~100 ms
sh -c + C helper + gateway 7.3–8.2 8.6–11.8 ~4.8 ms
dash -c + C helper 3.5 4.3–4.9 ~2.6 ms
C helper executed directly + gateway 2.16 2.87 1.5 ms (+0.03 ms in main)
Go port executed directly + gateway 3.08 3.85 2.2 ms

On macOS /bin/sh is bash, and wrapping the command in sh -c adds about 5 ms. That is more than the difference between the languages. Environment prefixes in the command (VAR='…' '/path/helper' args) cost nothing measurable.

Proposal

  • Add a native helper that is a drop-in replacement for hook-helper.mjs and permission-gate.mjs, with the same argv, env, stdin and socket protocol. It would be C (libc only) or Rust. A prototype of the lifecycle helper already exists from the measurements: 500 lines of C, plus the 35-case parity test. The decision gate (permission-gate.mjs) is not ported yet; it adds a request id, a sha256 of the input and a bounded preview.
  • Use it for the command-only CLIs. Keep the JS helpers as the fallback when the binary is missing or fails its start-up self-check, and on platforms without a build.
  • Estimated effect: about 100 ms becomes about 3.5–8 ms per hook call, and CPU per call drops from about 100 ms to about 2–5 ms. With decision hooks that is roughly 200 ms saved per Codex tool call.

Costs and risks

  • Per-OS builds in CI. macOS arm64 and x86_64, or one universal binary; Linux x86_64 and arm64; Windows would need a named-pipe variant or could keep the JS helper. A Rosetta build costs about 9 ms more per exec, so the macOS binary must be native arm64 or universal.

  • Signing. On macOS the binary has to be signed with the app's Developer ID and notarized inside the .app. The quarantine and Gatekeeper path for a newly written executable was not tested; it could show a dialog, which is one more reason to ship the binary inside the signed bundle and never write it at run time.

  • A stable install path. The first exec of a new file costs 200–330 ms on macOS (signature, policy and XProtect checks, paid once per file):

    signing first exec warm
    ad-hoc, linker-signed 217 ms 2.1 ms
    codesign -s - 327 ms 2.2 ms
    codesign -s - -o runtime 336 ms 2.1 ms
    Go 222 ms 2.8 ms
    x86_64 under Rosetta 354–377 ms 9.8–9.9 ms

    So the helper must be run from a fixed path inside the app, never copied or extracted per session. After an app update, the first hook call pays the cost once.

  • Maintenance. A second implementation of the hook protocol has to stay byte-compatible with the JS helpers. A shared parity test (the 35 cases above) in CI would enforce that.

  • Static linking is impossible on arm64 macOS. The kernel kills static Mach-O binaries, so dyld and libSystem are always loaded; about 1.9 ms is the floor.

Question for the maintainer

Is native code (a small C or Rust binary built per platform in CI, signed and notarized with the app) acceptable in CanvasTTY? If it is:

  1. Which language do you prefer? C is the smallest and fastest; Rust is safer to maintain and should be about as fast on a current toolchain.
  2. Should Windows get a native named-pipe build, or keep the JS helper there?

If native code is not acceptable, the cheaper option is to keep JS and cut only the shell wrapper. That saves about 5 ms out of about 100 ms, so most of the cost remains.

Activity

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

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