From 497958b1b923308b7f8cd9a8c55c88559da7dd08 Mon Sep 17 00:00:00 2001 From: Shun Kakinoki Date: Wed, 14 Oct 2026 13:16:45 +0800 Subject: [PATCH] fix(openfactor): skip tool hooks when no orchestration daemon listens Every harness tool call started the ~90 MB Bun OpenFactor CLI, about 0.7 CPU-seconds each, only for the CLI to find no daemon socket and drop the event. Activation now wraps the non-prompt host hooks in a `test -S ` check and gives the OpenCode plugin the same check, so tool hooks cost one shell builtin outside orchestrated lanes. Prompt hooks stay ungated because the CLI records lane prompt receipts from them without a daemon. Co-Authored-By: Claude Opus 5.5 --- config/openfactor/default.nix | 1 + config/openfactor/install.sh | 37 ++++++++++++++++++++++++ spec/openfactor_spec.sh | 53 ++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/config/openfactor/default.nix b/config/openfactor/default.nix index 546db6b3e..c581dc604 100644 --- a/config/openfactor/default.nix +++ b/config/openfactor/default.nix @@ -9,6 +9,7 @@ let pkgs.curl pkgs.gnutar pkgs.gzip + pkgs.jq ]; in { diff --git a/config/openfactor/install.sh b/config/openfactor/install.sh index bac2e411f..e34a7331c 100755 --- a/config/openfactor/install.sh +++ b/config/openfactor/install.sh @@ -48,6 +48,34 @@ if ! "$OPENFACTOR_BIN" hooks install --scope host --json; then echo "warning: OpenFactor hook registration failed; continuing activation" >&2 fi +# Each hook boots the ~90 MB Bun CLI (~0.7 CPU-seconds) only to POST the event +# to the orchestration daemon socket, and drops it when no daemon listens there. +# Check the socket first so tool-call hooks cost nothing outside orchestrated +# lanes. Prompt events stay ungated: the CLI records lane prompt receipts from +# them without a daemon. The gated command still names openfactor, so later +# `hooks install` runs keep it instead of appending a second entry. +# shellcheck disable=SC2016 +socket_gate='test -S "${OPENFACTOR_ORCHESTRATION_SOCKET:-${OPENFACTOR_ORCHESTRATION_STATE:-.orchestration-state}/daemon.sock}" || exit 0; exec ' +receipts="${XDG_STATE_HOME:-$HOME/.local/state}/openfactor/hooks" +for receipt in "$receipts"/*.json; do + [[ -f $receipt ]] || continue + config="$(jq -r '.configPath // empty' "$receipt" 2>/dev/null || true)" + [[ $config == *.json && -f $config && ! -L $config ]] || continue + gated="$(mktemp "$config.XXXXXX")" + if jq --arg gate "$socket_gate" ' + walk( + if type == "string" + and test("^openfactor [a-z]+-hook [A-Za-z.]+$") + and (test("[Pp]rompt") | not) + then "sh -c '\''" + $gate + . + "'\''" + else . end + )' "$config" >"$gated" && ! cmp -s "$gated" "$config"; then + mv "$gated" "$config" + else + rm -f "$gated" + fi +done + # The generated OpenCode plugin spawns one CLI process per event, and the # message part events fire on every streamed token. Under a busy T3 server that # forks hundreds of hooks per second and drives the host load into the @@ -80,6 +108,15 @@ if [[ -f $opencode_plugin && ! -L $opencode_plugin ]] && grep -q 'stdin: "pipe"' ' "$opencode_plugin" fi +# Same socket gate as the JSON hooks, applied before the plugin spawns the CLI. +if [[ -f $opencode_plugin && ! -L $opencode_plugin ]] && ! grep -q 'daemon\.sock' "$opencode_plugin"; then + # shellcheck disable=SC2016 + "${OPENFACTOR_PERL:-perl}" -0pi -e ' + s{closeSync, openSync, unlinkSync}{closeSync, existsSync, openSync, unlinkSync}; + s{(function send\(name: string, payload: unknown\): void \{\n)}{$1 if (name !== "chat.message" && !existsSync(process.env.OPENFACTOR_ORCHESTRATION_SOCKET || join(process.env.OPENFACTOR_ORCHESTRATION_STATE || ".orchestration-state", "daemon.sock"))) return;\n}; + ' "$opencode_plugin" +fi + # The generated pi extension writes each payload to the hook over a socketpair. # pi exits right after session_shutdown, and the Bun-built CLI then blocks on # that stdin forever, so every pi run strands a few hooks. Hand the payload diff --git a/spec/openfactor_spec.sh b/spec/openfactor_spec.sh index cb11fc648..e5987e78b 100644 --- a/spec/openfactor_spec.sh +++ b/spec/openfactor_spec.sh @@ -128,12 +128,63 @@ TS When run env -u XDG_CONFIG_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true "$BASH_BIN" "$SCRIPT" The status should be success The contents of file "$PLUGIN" should include '{ stdin: fd, stdout: "ignore", stderr: "ignore" }' -The contents of file "$PLUGIN" should include 'import { closeSync, openSync, unlinkSync, writeFileSync } from "node:fs";' +The contents of file "$PLUGIN" should include 'import { closeSync, existsSync, openSync, unlinkSync, writeFileSync } from "node:fs";' +The contents of file "$PLUGIN" should include 'if (name !== "chat.message" && !existsSync(' The contents of file "$PLUGIN" should include '"message.updated"' The contents of file "$PLUGIN" should not include '"pipe"' The contents of file "$PLUGIN" should not include 'message.part' End +Describe 'daemon socket gate' +setup_gate() { + TMP_HOME="$(mktemp -d)" + CONFIG="$TMP_HOME/.claude/settings.json" + mkdir -p "$TMP_HOME/.claude" "$TMP_HOME/.local/state/openfactor/hooks" "$TMP_HOME/bin" + printf '{"configPath":"%s"}\n' "$CONFIG" >"$TMP_HOME/.local/state/openfactor/hooks/claude-host.json" + cat >"$CONFIG" <<'JSON' +{"hooks":{ + "PreToolUse":[{"hooks":[{"type":"command","command":"openfactor claude-hook PreToolUse"}]}], + "UserPromptSubmit":[{"hooks":[{"type":"command","command":"openfactor claude-hook UserPromptSubmit"}]}], + "Stop":[{"hooks":[{"type":"command","command":"other-tool openfactor claude-hook Stop"}]}] +}} +JSON + printf '#!/bin/sh\necho "ran $*"\n' >"$TMP_HOME/bin/openfactor" + chmod +x "$TMP_HOME/bin/openfactor" +} +cleanup_gate() { rm -rf "$TMP_HOME"; } +BeforeEach 'setup_gate' +AfterEach 'cleanup_gate' + +install_twice() { + env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" && + env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" && + jq -r '.hooks | .PreToolUse[0].hooks[0].command, .UserPromptSubmit[0].hooks[0].command, .Stop[0].hooks[0].command' "$CONFIG" +} + +It 'gates tool hooks on the daemon socket and leaves prompt hooks alone' +When run install_twice +The status should be success +The line 1 of output should equal "sh -c 'test -S \"\${OPENFACTOR_ORCHESTRATION_SOCKET:-\${OPENFACTOR_ORCHESTRATION_STATE:-.orchestration-state}/daemon.sock}\" || exit 0; exec openfactor claude-hook PreToolUse'" +The line 2 of output should equal 'openfactor claude-hook UserPromptSubmit' +The line 3 of output should equal 'other-tool openfactor claude-hook Stop' +End + +run_gated() { + env -u XDG_STATE_HOME HOME="$TMP_HOME" OPENFACTOR_BIN=/usr/bin/true bash "$SCRIPT" && + cd "$TMP_HOME" && + PATH="$TMP_HOME/bin:$PATH" sh -c "$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$CONFIG")" && + mkdir -p .orchestration-state && + python3 -c 'import socket,sys; socket.socket(socket.AF_UNIX).bind(sys.argv[1])' .orchestration-state/daemon.sock && + PATH="$TMP_HOME/bin:$PATH" sh -c "$(jq -r '.hooks.PreToolUse[0].hooks[0].command' "$CONFIG")" +} + +It 'runs the CLI only when the daemon socket exists' +When run run_gated +The status should be success +The output should equal 'ran claude-hook PreToolUse' +End +End + It 'warns and continues when OPENFACTOR_BIN is not executable' TMP_HOME="$(mktemp -d)" When run env HOME="$TMP_HOME" OPENFACTOR_BIN="$TMP_HOME/missing" "$BASH_BIN" "$SCRIPT"