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:
- 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.
- 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.
Problem
Every lifecycle hook and every decision hook (
PreToolUse) of a command-only CLI startshook-helper.mjsorpermission-gate.mjsthrough the Electron binary withELECTRON_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 firesPostToolUseafter every tool, and with decision hooksPreToolUsefires 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 withhook-helper.mjson 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 %.-O2)encoding/json,regexpornet)-s -w,CGO_ENABLED=0)The lower bounds of a process start, for comparison:
/usr/bin/truetakes 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
sh -c+ Electron-as-Nodesh -c+ C helper + gatewaydash -c+ C helperOn macOS
/bin/shis bash, and wrapping the command insh -cadds 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
hook-helper.mjsandpermission-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.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):
codesign -s -codesign -s - -o runtimeSo 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:
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.