Skip to content

feat(hooks): add Codex-compatible agent lifecycle hooks - #1785

Merged
bobleer merged 5 commits into
GCWing:mainfrom
bobleer:bob/bitfun-agent-hook-c56cd6
Jul 27, 2026
Merged

feat(hooks): add Codex-compatible agent lifecycle hooks#1785
bobleer merged 5 commits into
GCWing:mainfrom
bobleer:bob/bitfun-agent-hook-c56cd6

Conversation

@bobleer

@bobleer bobleer commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds native agent lifecycle hooks: user-configured commands that run at eleven points in the BitFun Agent's lifecycle (tool calls, permission prompts, prompt submission, context compaction, subagents, session/turn boundaries).

BitFun could previously only inspect hooks configured for other AI applications (the read-only /hooks catalog). There was no way to run your own command when the Agent does something. This adds that.

The configuration document, event names, stdin payload fields, exit-code semantics, and stdout decision schema match the Codex hook contract, so an existing Codex hook script runs unchanged in BitFun and vice versa.

Type and Areas

Type: Feature

Areas: Rust core (assembly/core, execution/agent-runtime), web UI (settings), docs, locales

Motivation / Impact

Hooks are the standard way users automate policy around a coding agent: block edits to generated files, run a formatter after every write, require a clean test run before the Agent finishes, inject project context at session start, or auto-approve a tool that is always safe. Codex, Claude Code, and OpenCode all ship this; BitFun could only look at their configuration files.

Matching the Codex contract exactly (rather than inventing a BitFun-specific one) means users bring their existing scripts with them, and the repo's existing codex-adapter understanding of that format stays the single source of truth.

Impact when no hooks are configured: none — dispatch early-outs before any process work.

Settings UI

Settings → Smart Capabilities → Agent Hooks exposes both gates, states where hooks.json lives on each platform, and links out to the Codex reference. Without it the gates could only be reached by hand-editing app.json. New settings/hooks i18n namespace in en-US / zh-CN / zh-TW.

Docs approach

BitFun implements the Codex hook contract rather than a dialect of it, so the docs lead with that and send readers to https://learn.chatgpt.com/docs/hooks for the event, payload, and decision reference. What stays is only BitFun-specific: file locations, the app.hooks gates, and an explicit deviations table ("not supported" / "fields not populated yet" / "behavior worth knowing"). This keeps the pages roughly half their original length and avoids maintaining a second copy of a reference that would drift out of sync with upstream.

Architecture

Follows the layering in src/crates/*/AGENTS.md:

  • bitfun-agent-runtime::native_hooks — portable engine: settings parsing, matcher evaluation, payload construction, process execution with timeouts, decision merging. No dependency on assembly/adapters/apps.
  • bitfun-core::native_hooks — product wiring: config discovery, gating, one typed dispatch helper per event. The coordinator, execution engine, and tool pipeline call those helpers instead of executing hooks inline.

Three separate things now share the word "hook"; the AGENTS.md update spells out the split: these native user hooks, the internal compiled-in post_call_hooks, and the read-only external hook catalog (external_hooks).

Security posture

  • Project hooks are off by default (app.hooks.project_hooks_enabled). A project hook file executes commands from the checked-out repository, so anyone who can land a commit could otherwise run code on a reviewer's machine.
  • A hook can narrow the permission policy, never widen it. A PreToolUse allow waives the interactive prompt only; a tool call denied by a permission rule stays denied.
  • The payload reaches hooks on stdin, never interpolated into the shell command, so model-controlled text cannot become shell syntax.
  • Remote workspaces skip hook dispatch entirely — a local hook process and a remote workspace path do not describe the same filesystem. The check is derived from the workspace binding and fails closed.
  • Every handler is bounded by a timeout (including the stdin write), and captured output is bounded.

Verification

Fully tested. 59 tests added, all passing:

cargo test -p bitfun-agent-runtime --test native_hook_settings_contracts   # 17 passed
cargo test -p bitfun-agent-runtime --test native_hook_payload_contracts    #  8 passed
cargo test -p bitfun-agent-runtime --test native_hook_execution_contracts  # 22 passed
cargo test -p bitfun-core --lib native_hooks                               # 10 passed
cargo test -p bitfun-core --lib hook_approval                              #  2 passed

The two hook_approval tests pin the security invariant in the tool pipeline:
a hook approval waives the interactive prompt, but a policy Deny still rejects
the call and the tool never runs.

The execution tests spawn real subprocesses to pin the process contract: payload delivery on stdin, exit code 0 with JSON decisions, exit code 2 blocking with stderr as the reason, other exit codes warning without blocking, timeouts killing the handler, and matcher selection.

Also run:

cargo check -p bitfun-core -p bitfun-agent-runtime -p bitfun-cli -p bitfun-server   # clean
cargo clippy -p bitfun-agent-runtime --all-targets                                  # clean for changed files
cargo clippy -p bitfun-core --all-targets                                           # clean for changed files
rustfmt on changed files only (per AGENTS.md `fmt:rs` guidance)

Not run locally: the frontend checks (type-check:web, i18n:audit, web-ui tests) — this machine has no Node toolchain installed, so the settings page and locale files are verified by CI's Frontend Build. Key parity across the three locales and a one-to-one match between the panel's 23 t() calls and its defined keys were checked by script.

Also not run locally: cargo check -p bitfun-desktopleptonica-sys (OCR native library) fails to build in this environment, which is pre-existing and unrelated to this change. No desktop code is touched and no Tauri command is added, so remote_workspace_policy.rs needs no new entry. Leaving that to CI.

Reviewer Notes

This work is AI-assisted. It was reviewed by a multi-agent adversarial pass (correctness / security / Codex-fidelity / integration reviewers, each finding independently verified by skeptics). Defects that pass found and that are fixed in this PR:

  • The gating config was read from the wrong dot-path, so app.hooks.enabled = false was silently ignored. The test that was supposed to cover it was vacuous; it now pins real path resolution.
  • The stdin write sat outside the timeout, so a hook that never reads a payload larger than the pipe buffer could hang the Agent forever. Worse, a hook that does read stdin while writing a large stdout would deadlock outright. The write now runs concurrently with the wait, inside one timeout; both scenarios have regression tests.
  • A PreToolUse allow bypassed the whole permission evaluation, including hard Deny rules. It now feeds into evaluation instead of replacing it, with two tests pinning the invariant.
  • The 10,000-byte model-visible cap was not applied to JSON-supplied text, only to plain stdout.
  • Stop fired for subagent turns, which SubagentStop already covers.
  • The remote-workspace check in the coordinator read session config, which can legitimately lose its remote connection id; it now uses the workspace binding and fails closed.

Deliberately left for follow-ups (all low impact; fixing them here would cost more risk than it buys):

  • If the global config service is unavailable, the gates fall back to their defaults (user hooks on, project hooks off). The fallback is the safe side, but it does mean enabled: false is not honored in hosts that never initialize the service.
  • A updatedInput rewrite lands after the concurrency-safety batching decision, so a call rewritten from a read into a write could still run inside a parallel batch. Reordering execute_tools for this is riskier than the narrow scenario warrants.
  • The per-workspace engine cache evicts the lexicographically smallest key rather than the oldest. Worst case is one extra config re-read out of 32 slots.

Known gaps are documented rather than implied — see the "Current gaps" section of the docs. transcript_path is always null, SessionStart.source is always startup, permission_mode only takes two of the five Codex values, and SubagentStop does not fire on subagent failure. These are honest deviations, not silent ones; they are the natural follow-ups.

Docs: docs/features/agent-hooks.md and docs/features/agent-hooks.zh-CN.md — full event reference, the process interface, worked examples, a security section, and a troubleshooting table. Linked from README (both languages) and AGENTS.md/AGENTS-CN.md.

Checklist

  • This PR is focused and does not include secrets, temporary prompts, generated scratch files, or unrelated artifacts.
  • Relevant verification is recorded above, or skipped checks are explained.
  • User-facing strings, docs, and locales are updated where applicable.

bobleer added 5 commits July 26, 2026 09:37
BitFun could only inspect the hooks other AI applications had configured;
it had no way to run a user's own command at an agent lifecycle point.

Add native hooks that execute user-configured commands at eleven lifecycle
events. The configuration document, event names, stdin payload fields,
exit-code semantics, and stdout decision schema match the Codex hook
contract, so an existing Codex hook script runs unchanged in either tool.

- `bitfun-agent-runtime::native_hooks` owns the portable engine: settings
  parsing, matcher evaluation, payload construction, process execution with
  timeouts, and decision merging.
- `bitfun-core::native_hooks` owns config discovery, gating, and one typed
  dispatch helper per event; the coordinator, execution engine, and tool
  pipeline call those helpers.
- Hooks are read from `<user config>/config/hooks.json` and, when explicitly
  enabled, `<workspace>/.bitfun/config/hooks.json`. Project hooks stay off by
  default because they execute commands from the checked-out repository.
- A PreToolUse `allow` waives the interactive permission prompt only; a
  permission-policy deny still rejects the call, so hooks can narrow the
  policy but never widen it.
- Hooks are skipped for remote workspaces, where the hook process and the
  workspace path would not describe the same filesystem.

Documented in docs/features/agent-hooks.md (English) and
docs/features/agent-hooks.zh-CN.md (Chinese), including the deviations from
Codex and the payload fields that are not populated yet.

Covered by 56 tests across settings parsing, payload construction, the
process interface (real subprocesses for exit codes, timeouts, and stdout
decisions), and product wiring.
A PreToolUse hook approval waives the interactive permission prompt only.
Cover both halves of that invariant: a policy Deny still rejects the call
and the tool never runs, while an Ask-only policy proceeds without raising
a permission request.
A handler that reads stdin and echoes it back blocks on its own stdout once
the payload exceeds the pipe buffer in both directions. Writing the payload
before waiting for the child would deadlock there; the engine drives the
write and the wait concurrently, and this pins that.
The hooks capability had no UI: the enablement gates could only be reached
by hand-editing app.json. Add a settings page under Smart Capabilities that
exposes both gates, explains where hooks.json lives, and links out to the
Codex hook reference.

Also rewrite the feature docs around the fact that BitFun implements the
Codex hook contract rather than a dialect of it. The docs now lead with that
compatibility, send readers to https://learn.chatgpt.com/docs/hooks for the
event, payload, and decision reference, and keep only what is BitFun-specific:
file locations, the app.hooks gates, and an explicit deviations table. That
halves their length and removes a second copy of a reference that would
otherwise drift out of sync with upstream.
SETTINGS_TAB_SEARCH_CONTENT is a Record<ConfigTab, ...>, so adding a tab
without a matching entry fails the type-check. Give the Hooks tab its
section titles and descriptions so settings search matches its content.
@bobleer
bobleer merged commit 0c12633 into GCWing:main Jul 27, 2026
5 checks passed
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.

1 participant