Loadbudget

AGENT_HOOKS

Which hook does each agent run, and why does a cell stay empty?

Binds: architecture, process

Claude Code and AGY (the Antigravity CLI) work in this repository. Each one reads its hooks from a file of its own. This page holds the table of those files. tools/tests/test_agent_hooks.py holds the same table as data, and it fails where a file and the table disagree. The table and the protocol of each agent bind architecture. What a hook asks of a session binds process: the sync with main, and just lb pr watch.

OpenCode is another agent. It reads no JSON hooks, so the table carries no column for it. The last sections of this page state what OpenCode runs and what it denies.

Each agent runs every hook that its harness can carry

Hook action Claude AGY
just lb session sync-main SessionStart PreInvocation
just lb prek install-shims SessionStart PreInvocation
just lb pr watch PostToolUse Bash, mcp__github__create_pull_request none
just lb app shutdown SessionEnd none
aspect gc --force --base-max-idle-days=3 SessionStart none
tools/hooks/sweep-nested-output-bases.py SessionStart none
.claude/hooks/worktree-pool.py, lease WorktreeCreate none
.claude/hooks/worktree-pool.py, release WorktreeRemove none
.claude/hooks/worktree-pool.py, adopt SessionStart none
tools/hooks/refuse-git-worktree-add.py PreToolUse Bash PreToolUse run_command

The files:

PR_GUARD.md states the Git pre-push review.

The base sync holds the first model call

just lb session sync-main fetches main before the agent starts work. It routes to agent_session_start_for_git_repos.sync, and tools/agent-session-start-for-git-repos/docs/README.md states what it moves and what it only reports. The [tool.agent-session-start] table of the root pyproject.toml names the trunk, the rerun command and the page that the agent rereads. It runs synchronously, within the timeout that SYNC_TIMEOUT there names, so the first model call waits for it. just lb prek install-shims stays in the background.

Claude runs the sync on startup, resume and fork. A resumed branch that carries work gets a report, and the sync moves nothing there.

No agent documents whether its start hook runs before its instruction file loads. So the text of the sync asks the agent to read AGENTS.md again after a move.

AGY reads its hook file in a shape of its own

An AGY allow grants no permission. After an allow, AGY still applies its own permission rules to the call. A headless run then refuses a command that no rule allows.

AGY wraps handlers in hooks only under an event that takes a matcher. A PreToolUse entry holds matcher and hooks. A PreInvocation or Stop entry is the handler itself, with type and command. AGY 1.2.14 rejects the whole file for one PreInvocation or Stop entry that wraps its handler in hooks. It then runs no hook of the file.

A Claude hook starts at the project root, not in the working directory

A hook starts in the working directory of the session. The Bash tool keeps that directory between calls. So a cd into .git moves every later hook there. In .git, git rev-parse --show-toplevel exits 128, and a hook that reads a path of the checkout finds nothing there.

Each command in .claude/settings.json that runs just or a script by a relative path starts with cd "$CLAUDE_PROJECT_DIR" &&. A tool hook replaces && with the form that the section “A Claude tool hook exits 0 after a release removes the checkout” states. The script .claude/hooks/session-start.sh is the exception. It runs just in the directory that the session starts in, and nobody checked where a resumed session starts.

AGY keeps the old form. Its hook file names no variable that holds the project root, and nobody checked whether AGY offers one. The cd "$(git rev-parse --show-toplevel)" prefix of the AGY hooks does not fail inside .git. The cd then gets an empty argument and stays in .git.

A Claude tool hook exits 0 after a release removes the checkout

AGENTS.md makes aspect worktree release the last step of a session. The release removes the checkout, and CLAUDE_PROJECT_DIR still names it. The PostToolUse hook of that call and the PreToolUse hook of each later Bash call then find no script.

Claude Code 2.1.316 ran these hooks with /bin/sh, and /bin/sh on Ubuntu is dash. A failed cd in dash exits 2. python3 exits 2 for a script that it cannot open. Exit 2 from PostToolUse reports a blocking error on a release that passed. Exit 2 from PreToolUse refuses the Bash call.

So each tool hook starts with cd "$CLAUDE_PROJECT_DIR" 2>/dev/null || exit 0;. With no checkout, the session can run git worktree add, because the script that refuses it is gone.

The other events keep &&:

Claude Code denies the Xcode tools that run or write past every rule

.claude/settings.json denies InvokeDebuggerCommand. The tool sends a line to LLDB in Xcode. platform shell in that line runs a shell command, and script runs Python. No hook reads that call as a shell command. .mcp.json registers no lldb server for the same reason.

.claude/settings.json denies two more groups of Xcode tools outright. One group writes into Loadbudget.xcodeproj, which XcodeGen writes from ios-app/project.yml. The other group writes files outside every rule on Edit or Write.

xcrun mcpbridge serves the Xcode tools, the device tools among them. A tools/list call to the running Xcode server names the whole tool list. A later Xcode can serve another set.

AGY syncs the base before each prompt

AGY names no SessionStart event. PreInvocation runs before each model call. Its input carries invocationNum, which counts the calls of one prompt from 0. AGY starts that count at 0 again for each prompt. So the base sync runs before the first model call of every prompt. It answers {} on every later call of that prompt. Its text reaches the model as an ephemeralMessage step. Where just fails, the hook still prints {}.

AGY runs the PreInvocation hooks one after another, in the order of the file. So the install hook starts after the base sync ends.

The install hook prints {} at once and tests these hooks. Where the pre-commit and post-commit shims exist and the pre-push hook names just review-push, it stops there. A later call then costs one file read for each hook. Otherwise it starts the same verb as the other agents, in the background.

A Claude session end leaves the shutdown in the background

Claude Code gives all SessionEnd hooks one shared budget of 1.5 seconds. A longer timeout raises that budget, up to 60 seconds. Claude Code cancels a hook that reaches its timeout: https://code.claude.com/docs/en/hooks. .claude/settings.json sets each SessionEnd hook to 3 seconds. The hook starts just lb app shutdown with nohup, and the launcher returns at once.

Some cells stay empty, and each one has a reason

The pull request watch starts for Claude alone. The hook runs no watch. It tells the session to start just lb pr watch under the Monitor tool, and only Claude Code carries that tool.

Only Claude runs aspect gc. The command sweeps every output base of the machine, whichever agent built it. Where PATH contains aspect, .claude/hooks/session-start.sh runs the command in the background. A failure stops no session.

Only Claude leases worktrees from the pool. Claude Code names a WorktreeCreate and a WorktreeRemove event. Nobody checked whether AGY offers an event that replaces its worktree creation. An AGY session then holds no lease of the pool, so it has no lease to adopt at its start.

AGY has no session-end shutdown. AGY names no SessionEnd event. Its Stop event runs at the end of a response, and a shutdown there stops a simulator that the next response still needs.

The watch hook answers a create that printed a URL

The matcher of the hook names Bash and mcp__github__create_pull_request. A session opens a pull request with gh pr create. The MCP name serves a cloud session, which infra/docs/usage/CLOUD_SESSION.md states.

.claude/hooks/pull-request-opened.py reads the call. It answers a Bash call only where the command names gh pr create and the output holds a /pull/<n> URL. A text that only quotes the command starts no watch. It answers an MCP create where the answer of the tool holds that URL, in any shape of answer. A create that fails prints no URL, so it starts no watch either. The Claude Code hooks page does not state whether a Bash call that exits non-zero reaches PostToolUse.

The script runs on the python3 of the machine, with no package, because no machine setup installs jq.

A Claude worktree leases a slot of the Aspect pool

Bazel keys an output base on the path of its workspace. A new worktree path thus builds a new output base and starts a cold server, and nothing in Bazel removes the base after the worktree goes. The cleanupPeriodDays setting of Claude Code times a cleanup of old worktrees, and that cleanup keeps a worktree with unpushed commits. A rebase merge rewrites the commits of a branch, and GitHub deletes the merged branch. So the cleanupPeriodDays cleanup keeps every merged worktree.

.claude/hooks/worktree-pool.py runs on WorktreeCreate. Where aspect is on the PATH, it runs aspect worktree add --detach origin/main, after a fetch of main. The slot keeps its path from one lease to the next, so the next lease finds the output base and the server warm. The worktree starts detached, so the hook names no branch. The session creates its branch as it does anywhere.

After the fetch, a create also fast-forwards the main checkout. A Remote Control bridge reads this script and .claude/settings.json from that checkout. A stale checkout then runs a stale hook. The move happens only where the checkout stands on main, holds no change and carries no commit of its own. Every other checkout stays, and the create goes on. At each start of the bridge, infra/ansible/roles/remote-control-bridge/files/fast-forward-checkout.sh makes the same move.

On WorktreeRemove the script runs aspect worktree release. The release refuses a worktree that holds uncommitted work or commits that no branch reaches. The removal then fails, and the worktree stays.

Where aspect is absent, the script runs git worktree add --detach under .claude/worktrees/ and git worktree remove, with no --force. git worktree remove checks only for changed files, so the HEAD check above also keeps a commit that no branch holds.

When a Remote Control bridge stops, it keeps the worktree of each session. When the next bridge resumes the session, it asks for a worktree of the same name. So a create that finds a worktree of that name in git worktree list returns that path as it stands, with no fetch. On both routes, it looks first for .claude/worktrees/<name>. On the aspect route, each lease records the name with --agent-id. The create then looks in aspect worktree list for the slot that a lease of the name holds. A directory of that name that git does not record still fails the create on the git worktree route.

A lease records the name, and not the session id. Without --agent-id, the pool reads CLAUDE_CODE_SESSION_ID from the hook. In a bridge, the hook runs in the bridge process. So every session of the bridge shares that id, and the id changes at each start of the bridge. The name bridge-cse_<id> stays the same across a resume.

A session adopts the lease that the bridge took for it. The lease records the pid of the bridge, and the bridge outlives each session. So aspect reads the lease as held by another running agent, and it refuses the release. On SessionStart, .claude/hooks/session-start.sh hands the event to the pool script. The script finds the slot that holds the working directory of the session. From inside that slot, it runs aspect worktree inspect --agent-id <name> with the name of the lease. aspect then moves the lease to the process of the session. It moves only a lease that carries host_pid, and only while the lease still names the bridge, or names a process that stopped. The release of aspect that .aspect/version.axl pins records host_pid for a lease taken in a bridge. A lease taken before Aspect recorded host_pid carries none, and the command then moves nothing.

The hook replaces the git logic of Claude Code, so worktree.baseRef and .worktreeinclude no longer apply. The fetch in the script stands in for baseRef. Every worktree of a clone shares one ref store, so another fetch can hold or move refs/remotes/origin/main. REF_RACES in the script names what git then reports. The script retries that failure quickly, then slowly. Any other failure prints a warning, and it gets the slow retries. The last failure blocks the create.

When a subagent finishes, Claude Code runs no WorktreeRemove. Its slot stays leased. A slot whose session has stopped, whose Worktrees.abandoned_grace_hours have passed and whose checkout is clean falls to aspect worktree add. .aspect/config.axl sets that grace to 2 hours, against a default of 24. A session that resumes later than that finds its clean slot taken. A dead session with a dirty checkout keeps its slot until somebody runs aspect worktree release --force=all.

The pool limit warns, and it refuses nothing. Worktrees.max_slots defaults to 8 slots for each clone, and .aspect/config.axl sets 4. Where every slot is in use, add still creates a new slot, and it prints pool_over_limit. A refusal sends the session to git worktree add, with a cold build and an output base that nothing removes. add deletes a free slot that sat idle for 30 days.

A shell command of an agent cannot run git worktree add. tools/hooks/refuse-git-worktree-add.py runs on PreToolUse for Claude and AGY. Where aspect is on the PATH, it refuses the command, and its message names aspect worktree add. Where aspect is absent, it allows the command.

The hook matches git where a command starts. That is the start of a line, or the place after ;, &, | or (. So a quoted argument and a line of prose in a heredoc pass. A heredoc line that starts with the words is still refused. A wrapped form such as sudo git passes, and so does a backtick substitution, because the prose here quotes the words in backticks. The hook steers a session, and it is no security boundary.

aspect gc runs at each session start on a laptop and on lb-agents. It removes an output base whose workspace is gone for an hour, or that sat unused for 3 days. It removes the base of a pool slot only after 3 days unused, and never as orphaned. It keeps a base with a running server. It needs no Bazel workspace.

After aspect gc, the session start removes the base of a deleted nested checkout. A session can build in a checkout under its slot, such as .scratch/l1, and then delete it. aspect gc reads that base as a pool slot, so it keeps it for 3 days, and aspect-build/aspect-cli#1611 reports the fault. tools/hooks/sweep-nested-output-bases.py names each such base to aspect gc --force, once its checkout is gone, no server runs in it and no build used it for an hour.

OpenCode writes the shims through a plugin

OpenCode has no JSON hooks, so .opencode/plugins/session-start/index.ts carries the verbs instead. OpenCode discovers a plugin under .opencode/plugins/ on its own. It asks nobody to trust one. Source: https://opencode.ai/v2/docs/plugins

When the plugin loads, setup() runs. When a session opens in a location, that location loads its plugins. A fresh worktree is a new location, so the worktree lands inside the setup window. The plugin reads no session event. A session whose model call fails still loads its location, so the shim install runs there anyway.

The plugin spawns one child, detached. That child has the same budget that the Claude and the AGY session starts allow. The child outlives the private server of a one-shot opencode run, so a short session still ends with the shims in place. The service holds more than one plugin instance for one location. Each instance spawns the child. The shim verb answers at once on a busy lock, so the extra instances change nothing.

The plugin exports a bare object. @opencode/plugin does not resolve inside a repository-local plugin, and a plugin that imports it loads nowhere.

OpenCode denies what Claude Code denies, on an allow-all base

OpenCode carries no JSON hooks, and its permission rules live in .opencode/opencode.jsonc. OpenCode allows every action by default. The file keeps that base and adds only denies:

The allow-all base takes no ask rule for each shell command. It costs no prompt, and a shell command then runs with no review. tools/tests/test_opencode_permissions.py fails where the Xcode denies of the two files disagree. https://opencode.ai/v2/docs/permissions states the rule schema.

Source: https://antigravity.google/docs/hooks/.

The sandbox of Claude Code writes caches outside the worktree

uv and prek each keep a cache under ~/.cache/. The prek cache holds its hook environments and its log. While the sandbox of Claude Code is on, a command writes the worktree and the temporary directory alone. Without a grant, uv run and prek run then fail before they start their work.

sandbox.filesystem.allowWrite in .claude/settings.json grants those directories, and no other directory under the home directory.

The sandbox covers every child of a command. So a recipe of just and a Git hook write the same directories. The caches stay at their default paths, so a later command and a later session read the same cache.

The grant names the default paths. XDG_CACHE_HOME, UV_CACHE_DIR and PREK_HOME each move a cache. The grant does not follow the move. On such a machine, list the path in ~/.claude/settings.json.