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:
- Claude:
.claude/settings.json,.claude/hooks/session-start.shfor the session start, and.claude/hooks/worktree-pool.pyfor a worktree. - AGY:
.agents/hooks.json. AGY groups events under a name of its own, so the file holds asession-startand aworktreegroup. - Every agent:
tools/hooks/refuse-git-worktree-add.pyfor a shell command.
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 &&:
WorktreeCreatemust fail with no checkout, because it makes no worktree.- An exit code from
WorktreeRemovefails a removal only where the directory still exists. - The
SessionEndhook runs in the background, so it exits 0.
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 Xcode MCP tools that
.claude/settings.jsondenies. OpenCode names each onexcode_<tool>. - An edit of a file under
docs/research/. - A fetch of
developer.apple.com.
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.