Skip to content

Guard hooks ​

A hook is a program that the frontend runs at a fixed event, for example before a tool call or at the start of a session. The frontend sends the event as JSON on standard input. A PreToolUse hook runs before a tool call and can refuse it: exit status 2 refuses the call and shows the hook's standard error to the model, and an ask decision on standard output shows a prompt to the user. The hooks of this page use the Claude Code events PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, PermissionRequest, WorktreeCreate and WorktreeRemove.

The sources are in hooks/. harness install --apply copies them to ~/.claude/hooks/. Claude Code calls them through the hooks section of its settings, which harness install --apply writes from settings/settings.proposal.json. On the Bash matcher, the directory and worktree tools and the Kaimon tools, Claude Code runs one program, the guard package hooks/guard/, which holds every check of a tool call. Antigravity runs the same package, with --from antigravity, on every tool call. oh-my-pi runs it with --from omp through its guard extension adapters/omp/guards.ts, and OpenCode with --from opencode through its plugin adapters/opencode/plugins/guards.ts, on every tool call. Each of the three frontends runs its own copy of the package, which harness install writes below hooks/guard/ in its directory: ~/.gemini/config/hooks/guard/, ~/.omp/agent/hooks/guard/ and ~/.config/opencode/hooks/guard/. Each hook still has one source. hooks/probe.py holds the test cases of the hooks, and harness test runs it.

harness install --apply writes the command of each Python hook, in ~/.claude/settings.json and in Antigravity's hooks.json, as the absolute path of the interpreter of the harness's Python environment, then the hook's path, each one shell word, quoted where the path holds a space. So a hook runs on the environment's Python, not on the python3 that comes first on the PATH. Each Python hook keeps the shebang #!/usr/bin/env python3, for a run by hand such as the examples on this page. When the environment is missing, the command of a hook fails to start, with exit status 127. Claude Code reads an exit status other than 2 as a hook error that does not refuse the call, so under Claude Code such a hook fails open while the environment is missing. The guard's command ends in || exit 2, so a guard that cannot start refuses the call. Antigravity reads a handler that prints nothing as a block.

The hooks prevent accidents. They are not a security boundary: each one reads the text of a command. The security model says what they stop and what they do not stop. The page groups the hooks: first the guard package and its checks, then edit-scope.py, then worktree.py and the session hooks, and last the probe.

The guard package ​

The guard is one program in hooks/guard/ that decides a tool call before it runs. Claude Code runs it as a PreToolUse hook on the matchers Bash, mcp__ccd_directory__.*|EnterWorktree|ExitWorktree, mcp__kaimon__.* and WebFetch|WebSearch, with the command <python> ~/.claude/hooks/guard --from claude || exit 2. The Bash matcher runs rtk hook claude first, then the guard. Antigravity runs its copy as a PreToolUse hook on every tool call, with the command <python> ~/.gemini/config/hooks/guard --from antigravity; the adapters page describes its answers. The plugins of oh-my-pi and OpenCode run <python> <their directory>/../hooks/guard --from omp or --from opencode on every tool call, with the interpreter that guard-paths.json beside them names under python; they send the event as JSON and apply the answer {"block": …, "reason": …}.

A table for each frontend, frontends/claude.py, frontends/antigravity.py, frontends/omp.py and frontends/opencode.py, turns the payload into a neutral call: the action, the command, the paths, the paths below which the call reads, the working directory and the caller. The tables of Antigravity, oh-my-pi and OpenCode also run the path-list check path_list.py, which reads the frontend's guard-paths.json; Antigravity's table runs the lift check sandbox_lift.py on a shell call that it does not deny, which lifts a command out of the sandbox only where the line matches a closed grammar and Claude Code's sandbox rule lifts it. One module per check reads that call: the staging, write, sandbox, gh api, gh pr create, gh watcher, rm, archive, git write, web, Kaimon and directory checks below. Each check gives deny, ask, allow or pass with a reason. The guard answers the first of deny, ask, allow and pass that a check gives, with that check's reason, in the form of the frontend: for Claude Code exit 2 with the reason on standard error for deny, a JSON decision for ask and allow, and no output for pass. A tool that the table does not name passes. oh-my-pi and OpenCode cannot ask the user from a plugin, so their tables answer an ask as a block whose reason tells the model to ask the user in chat; plugin.py holds what the two tables share: the reading of the message, the size check of the command and the answer.

The directory check asks before a session leaves ~/Research, or removes a worktree. A change_directory or request_directory call of the Claude desktop app passes when the path resolves inside ~/Research, after ~, .. and symbolic links. An EnterWorktree call with a path that resolves inside ~/Research passes only when the path is in a repository's .claude/worktrees/; the check refuses any other such path. Claude Code asks the person for every EnterWorktree path outside .claude/worktrees/, as a safety check that neither a hook's allow nor auto mode approves. The worktrees here are in ~/Research/.worktrees/. The refusal names the call that needs no prompt: cd <path> as a Bash call of its own. An EnterWorktree call with only a name passes, because worktree.py then decides the place. An ExitWorktree call passes only with action: "keep" and no discard_changes. Every other such call gets an ask decision.

The guard fails closed. It refuses a payload that is not UTF-8 or not a JSON object, or that has no tool_name; for a tool that it checks, a tool_input that is no object, a Bash call without a command, and a command of more than 1 MiB. A check that must resolve a path against the working directory refuses the command when the payload names none. A guard that raises refuses the call with the error. The checks read a command without shlex: a # inside a word, a backslash-newline and a quoted operator are read as the shell reads them, and a quoted operator or a joined line is also read the other way, so that neither hides a part of the command. tests/guard/corpus.jsonl holds the decision of each payload that the probe checks, and each difference from the base's hook scripts and plugin checks, with its reason. A check of a shell command reads each path through hooks/guard/paths.py, which reads each directory and name once in each mode and asks each directory once whether it holds a .git; every check and every reading takes the walk of a word from one memo; the write check asks git once per work tree. A probe case finds no file-system read elsewhere in the package but a few named ones, which read once per process or one path of a directory call. The probe counts the reads of ten commands of 1 MiB (a long cd word, many rm operands, many git init outside a work tree), each at most two or three reads per distinct name of the command plus a constant, and the guard decides each of its timed commands of 1 MiB within 10 s. Test it from hooks/ with the environment's interpreter, which is Python 3.14; the guard exits with status 2 on a deny:

bash
echo '{"tool_name":"Bash","tool_input":{"command":"git add -nA"},"cwd":"/"}' | ~/.local/share/research-harness/python/bin/python guard --from claude

The staging check ​

no_blind_stage.py refuses a git add that stages more than the paths it names, on each shell call of every frontend.

It reads the command, splits it into segments, and finds each git add. It refuses -A, -u, --all, --update, --no-ignore-removal, a short-flag cluster that holds A or u, and the pathspecs ., ./ and :/. The flag cluster is the reason for the check: a deny glob cannot match git add -nA without also matching a file name that holds an A. The refusal tells the model to stage its paths by name.

The write check ​

no_shell_file_write.py refuses a shell command that writes a file in a git working tree, on each shell call of every frontend.

It refuses a redirection such as > or >>, tee, an in-place flag cluster of sed, perl or awk, and a python3 -c or a Python heredoc that opens a file to write. It resolves each target against the working directory and each cd of the command. A target under a scratch directory passes: $TMPDIR, /tmp/, /private/tmp/, /var/folders/, /dev/ or ~/Research/.scratch/. In a git working tree, every file is refused unless git ignores it. Outside one, only a file with a source suffix such as .jl or .md is refused. A quoted > is text, not a redirection. The refusal tells the model to use the Edit and Write tools. It does not see a target that the shell computes, a Julia one-liner, or a writer inside a string argument such as perl -e.

The sandbox check ​

sandbox_semantics.py refuses a command whose shape makes the Claude Code sandbox give a false result. Claude Code runs the whole check. Antigravity, oh-my-pi and OpenCode run its working-tree check alone on each shell call, and Antigravity's lift check reads the same rule to decide whether a command runs inside or outside the terminal sandbox.

The settings let a command run outside the sandbox only when every part of it starts with git, gh, glab or ps and no part redirects a file. A sandboxed gh cannot reach the network, and a sandboxed git cannot write .git. The failure then looks like a negative result or a certificate error. The check refuses four shapes:

  • a gh, glab or git write inside $( … ), backticks or a loop;
  • a top-level gh, glab or git write in a command that is not exempt, for example after cd other &&, or a gh command that holds a backtick or $(;
  • a $? in a command that runs sandboxed;
  • a path argument of git init, git clone, git worktree add, git worktree move or git bundle create that starts with /, ~ or $, or holds ...

It also refuses a git init or git clone whose target is inside a git working tree: the working-tree check. It follows each cd of the command to find the target. Under Claude Code a cd outside the agent's working directories is reset before the next call, so such a command runs in the agent's root and leaves a repository in another working tree. Each refusal gives the form that works, for example a separate Bash call.

The gh api check ​

gh_api_writes.py asks before a gh api write that is not a review or a comment, and refuses a remote delete, on each shell call of every frontend.

It finds each top-level gh api and glab api call and determines the HTTP method in the same way as gh: -X or --method; otherwise POST when a body flag is present; otherwise GET. A GET, a HEAD and a search/… endpoint pass. It refuses a DELETE from gh api or glab api. Any other glab api call passes. A review, an inline comment, a reply, and a comment on a pull request, an issue or a commit pass, so the review agents can post. A GraphQL query passes when it holds no mutation, or when each mutation is a review or comment operation. Every other write gets an ask decision, and the user sees a prompt. An edit of an existing comment also asks.

The check reads a GraphQL query where gh reads it: the text of -f query=…, which gh sends as it is; the file of -F query=@<path>; and the string query of the JSON object in the file of --input <path>. A relative path resolves against the call's working directory. A query that the check cannot read asks: a relative path in a call with no working directory, or in a command with a cd, pushd or popd; a file whose name a word of another part of the command holds, because that part can write the file before gh reads it, as cp other.graphql q.graphql && gh api graphql -F query=@q.graphql would; standard input (-); a path that names no regular file; a file that is not UTF-8; an --input that is no JSON object with a string query; and a call with more than one query. So write the query file in a call of its own. The check reads the file before gh runs, so a file that another process changes in between is not the file that it decided on.

A Git Data write in one of the user's own organisations also passes: a POST to repos/<owner>/<repo>/git/blobs, git/trees, git/commits or git/refs. These calls make a new branch, as a git push of a new branch does. The git/refs endpoint only creates a ref. A PATCH of git/refs/<ref> can move a branch by force, so it still asks. The own organisations are the org list of the profile: the file that RESEARCH_HARNESS_PROFILE names, else ~/.config/research-harness/profile.toml. The check compares the owner without case, and reads the profile once per process. A call with --hostname asks. The check fails closed: when the profile is missing, cannot be read, is not UTF-8 TOML, or has no org list of strings, the write asks. Every frontend runs the guard with the user's HOME and environment, so each one finds the same profile.

The gh pr create check ​

gh_pr_create.py decides a gh pr create by the repository that it names, on each shell call of every frontend. It finds each top-level gh pr create call and each call of its alias gh pr new, also after assignments such as GH_REPO=…, and reads every value of -R and --repo, in the forms -R x, -Rx, -R=x, --repo x and --repo=x, also at the end of a cluster of one-letter flags such as -dR x. The value of another flag that takes one, such as --title, is not a flag.

A call with no -R or --repo is refused: gh then opens the pull request in the repository that the checkout or GH_REPO names, which the command does not show. The reason tells the agent to add --repo <owner>/<repo>. A call with a value that is not <owner>/<repo> of an own organisation asks: another owner, a value with a host, and any value when the profile gives no own organisation. The check reads every value, not only the last one that gh takes, so a second --repo cannot send the call elsewhere. A call whose every value names an own organisation passes, so the allow rules let it run: gh pr create -R <org>/* and --repo <org>/*, first or after other arguments. No allow rule names the alias gh pr new, so Claude Code asks for it. The own organisations are those of the gh api check.

Under oh-my-pi, no pattern of harness.yml prompts for gh pr create, so the call runs under yolo also inside a pipe or after &&, and the guard extension refuses it or blocks it with the reason to ask the user.

The gh watcher check ​

gh_watch_interval.py refuses a gh watcher that polls the GitHub API faster than once every 30 seconds, on each shell call of every frontend. A watcher is a top-level gh run watch call or a gh pr checks call with --watch, also after assignments such as GH_REPO=…. The check reads the last value of -i or --interval, in the forms -i 60, -i60, -i=60, --interval 60 and --interval=60, because gh takes the last one.

A watcher with no interval is refused: gh run watch then refreshes every 3 seconds and gh pr checks --watch every 10 seconds. A watcher whose interval is not a whole number of at least 30 seconds is refused too. The reason tells the agent to add --interval 60. Each refresh makes several API calls, and all sessions and agents of one account share one rate limit. A watcher on a run of half an hour at the default interval makes thousands of calls and trips GitHub's secondary rate limit; after that, the REST Actions endpoints answer 403 for every session of the account until the limit lifts. git ls-remote and git fetch do not use the API, so they still read a branch, a tag or a gh-pages deploy.

The rm check ​

rm_scope.py refuses a recursive rm outside the scratch zones, and asks before a git rm or a git merge outside a worktree, on each shell call of every frontend.

It follows the working directory through each cd and resolves each operand. A recursive rm passes when every operand is strictly below a worktree, ~/Research/.worktrees/<name>/, or strictly below ~/Research/.scratch/. It refuses a recursive rm of /, of the parent directory of the home directories, /Users or /home, or of any path below it. So a worktree itself goes through git worktree remove. A plain rm, and an operand that it cannot resolve, get no decision. A git rm gets an ask decision outside a worktree, with -f or --force, or with -C, --git-dir or --work-tree. A git merge in a worktree passes: the merge is local, and git reset --hard ORIG_HEAD undoes it. Outside a worktree, or with -C, --git-dir or --work-tree, it gets ask, except a --ff-only or --no-edit merge with none of --no-ff, --squash, --no-commit and -m. So the settings allow git merge *, and keep an ask only for --abort. A command that holds rm or merge and that it cannot read gets ask.

The archive check ​

git_archive_output.py refuses a git archive that writes its file with -o or --output, on each shell call of every frontend, and names the commands that read the tree of a commit without a permission prompt.

An agent writes the archive to read the unmodified tree of a commit, usually the base of its branch. git runs outside the sandbox, so -o can write to any path. For this reason the settings have ask rules on -o, and the user gets a prompt for what is only a read. The check refuses the call, and its message names other commands. scripts/base-tree.jl puts the tree, or some paths of it, into a new directory. git show <rev>:<path> gives one file, and git ls-tree -r --name-only <rev> gives the list of files. A tar file that is itself the result goes to the user.

It finds each top-level git … archive call, after global options such as -C, and reads the option in each spelling that git takes: -o <file>, -o<file>, behind other short options as in -vo <file>, --output, --output=<file>, and any prefix of --output. An argument after -- is a path. A git archive to stdout passes.

The git write check ​

git_writes.py allows three git writes when the repository shows that nothing is lost, and asks otherwise, on each shell call of every frontend. A permission rule cannot make this decision: it does not see the working directory or the state of a worktree. A hook's allow does not lift a matching ask or deny rule in Claude Code, so the settings have no rule for these forms, and the check answers both ways.

  • git worktree remove --force <path>, or -f once: allow when the path is below ~/Research/.worktrees/, a remote-tracking ref contains the worktree's HEAD, and no tracked file is modified or staged. Untracked and ignored files can go. Otherwise ask, with a reason that names the commit or the files that the removal loses.
  • git cherry-pick: allow in a worktree below ~/Research/.worktrees/, or in a checkout whose origin is a GitHub repository of an own organisation. Otherwise ask.
  • git push --force-with-lease origin <branch>, or with HEAD:refs/heads/<branch>: allow when origin is in an own organisation and the lease names no ref or the pushed branch. Otherwise ask. In the <branch> form, the branch must be a local branch, and origin must have no remote.origin.push refspec, which can map the branch onto another remote branch.
  • A lease push that names main, master or the branch that origin/HEAD names as its target is refused, also inside a command of several parts such as git fetch && git push --force-with-lease origin main, after another write in the same part, as in a find with two -exec, and inside the script of sh -c, bash -c or eval. The check reads the target of each argument after push, the value of an option aside: <branch>, +<branch> and <src>:<branch>, each also with refs/heads/ or heads/, which git reads as refs/heads/. It reads origin/HEAD in the working directory of the call and in the repository that the push works on: the directory after each cd before it and each -C or --git-dir of its git. An option that the shell expands into a lease, such as --force-$w, counts as a lease. A lease push that names no branch can reach a default one, so it is refused too: no refspec after the remote, as with --all or --mirror, or a refspec whose target is HEAD, @ or empty, or that holds a glob, a brace, $ or a backtick. The value of an option such as -o, -vo or --recurse-submodules is no refspec.

The check allows only a command that is one git call, with no operator, no cd and no option before the subcommand. It finds the subcommand after the global options of git. Any other form of these writes asks, for example -C, -f twice or a lease after the remote. A worktree remove with any option counts as forced, because git reads -ff and a prefix of --force as force. A plain --force push gets no decision from the check; the deny rules of the settings refuse it. The check reads the state with git in the working directory of the call, with core.fsmonitor off. When git fails or cannot run, the call asks. The own organisations are the org list of the profile, as the gh api check reads it.

The web check ​

web_identity.py refuses a web request that identifies the user, on each web call and each shell call of every frontend. The web calls are Claude Code's WebFetch and WebSearch, OpenCode's webfetch and websearch, oh-my-pi's web_search and its read of an http or https URL, and Antigravity's read_url_content and search_web. The check reads each text percent-decoded and without case. It refuses:

  • a web call any of whose string arguments holds the query parameter mailto=, email=, e-mail=, api_key=, api-key=, apikey= or access_token= after ? or &, or holds the user's email address;
  • a shell command that holds such a parameter, or holds :// and the user's email address.

A literature index such as OpenAlex or Crossref asks automated clients for an email address or a key, and a fetched page can ask an agent to create a key with the user's email. The reason tells the agent to send the request anonymously and to report an index that rate-limits it as a failed source. A placeholder address is refused too, because the parameter is refused whatever its value. A parameter name in prose or in a pattern passes, such as grep 'mailto=', and so does a search for an author by name. The user's email address is git config --get user.email, read at most once per process; without git or without the key, the check reads the parameters alone. A request that a script builds from a file, and a request of a browser or an MCP tool, are beyond its reach.

The Kaimon check ​

kaimon_session.py decides every Kaimon call: Claude Code's mcp__kaimon__<tool>, Antigravity's call_mcp_tool calls to kaimon, oh-my-pi's mcp__kaimon_<tool> and OpenCode's kaimon_<tool>.

Each Kaimon tool takes its session key under one name, or under none: ex takes ses, the tools that address a session, such as manage_repl, type_info and run_tests, take session, and the others, such as check_eval, ping and start_session, take neither. Kaimon ignores a parameter that a tool does not take and then uses the session that the agent is bound to, or otherwise the only one connected, which can be another agent's. So the check refuses three calls:

  • a key under a name that the tool does not take, such as session on ex or ses on manage_repl; the message names the tool's own key;
  • an ex without ses, and a manage_repl without session;
  • a key that names no live session.

A session is live when its gate's file <cache>/sock/<session id>.json exists and the process of its pid runs; the key is a prefix of the session id. The cache is $KAIMON_CACHE_DIR, else $XDG_CACHE_HOME/kaimon, else ~/.cache/kaimon, as KaimonGate resolves it. Kaimon removes the file when the session shuts down or when it reaps a gone one, and a process that dies first leaves it behind. Kaimon finds a gate only through its file, so a key without one names no session that Kaimon routes. The socket files are no evidence, since Kaimon leaves them behind when a session ends. The check reads the files at each call and keeps nothing. It passes when it cannot tell: no directory, a file that cannot be read or holds no integer pid, a remote gate's key tcp-…. Under WSL, Kaimon also finds the gates of the Windows side, through files in a directory that the check does not read, so the check refuses their keys. manage_repl is not checked for a live session, because its shutdown removes a session whose process has gone. A tool that no table of the check names passes. The messages name the rule of skills/build-part/evidence.md.

The path list and oh-my-pi's own rows ​

path_list.py refuses a call that reaches a path of the frontend's guard-paths.json: for Antigravity, oh-my-pi and OpenCode, whose own permission rules bind only their file tools. Every path of a call and every word of a shell command meets the deny list; a write or an edit meets the edit list, which refuses, and then the ask list, which asks. OpenCode's table adds the list in $OPENCODE_CONFIG_DIR/guard-paths.json, whose allow refuses each home path and each absolute path below a directory of user data outside it, and refuses an in-place shell edit such as sed -i with a short message. oh-my-pi's table adds its own directory, ~/.omp, as a deny rule but for the copies of the skills, the rules, the instructions and RTK.md, and as an edit rule whole; reads the paths of a hashline or patch header; and refuses a sub-agent's write to agent://, which wakes a parked agent. Each table reads a missing or unusable list as a refusal of every call. It is a text check: a path that a command builds at run time passes it.

edit-scope.py ​

This hook asks once per worktree and session before an edit of a git hook or a CI workflow: a path below .githooks/ or .github/workflows/ under ~/Research. A git hook runs outside the sandbox at the next commit, and a workflow runs in CI with the repository's secrets when its branch is pushed. An ask on each such edit stops protecting anything once a person approves forty of them in a row.

Claude Code runs it on the Edit, Write, MultiEdit and NotebookEdit tools twice. As a PreToolUse hook it gets ask for such a path in a main checkout, and for one below a worktree, ~/Research/.worktrees/<name>/, unless this session holds a grant for that worktree. As a PostToolUse hook, edit-scope.py --record, it writes the grant after an edit there ran: only an approved edit runs, so only an approval makes a grant. The grants are in ~/.claude/logs/edit-scope.jsonl, which the sandbox and an Edit deny keep from a session. The settings hold no Edit ask for these paths, because an ask rule outranks the hook's silence. OpenCode, oh-my-pi and Antigravity run no such hook and ask on each edit: their path lists take the two paths from EDIT_SCOPE_ASKS of lib/harness/frontends.py.

It fails closed to a prompt on a call that it cannot read. A hook that does not run lets every such edit through, because the settings hold no rule for them. Test it:

bash
echo '{"session_id":"s","tool_input":{"file_path":"'"$HOME"'/Research/Harness/.githooks/pre-push"}}' | ./edit-scope.py

worktree.py ​

This hook creates and removes the worktrees of Claude Code. Claude Code runs it on the events WorktreeCreate and WorktreeRemove. The reason is the sandbox: a git worktree add to a path below ~/Research/.worktrees/ runs sandboxed, where it cannot write .git.

On WorktreeCreate it fetches origin in the repository that holds cwd. It then creates ~/Research/.worktrees/<Repository>-<slug> on the branch name, from origin/HEAD, or from origin/main if origin/HEAD is not set. It prints the path on standard output. An existing branch is checked out, and an existing worktree of the repository is printed as it is. A name agent-<hex>, from a sub-agent, gets a detached worktree with no branch. The hook copies the main checkout's Manifest.toml into the new worktree when the manifest holds every package of the worktree's [deps] at a version inside its [compat]. Otherwise it copies none and says why on standard error, because Julia does not check [compat] at using, and a stale manifest loads old dependencies. On WorktreeRemove it runs git worktree remove without --force, so git refuses a worktree with changes, and the branch stays.

It fails closed: on each error it exits with status 1 and gives the reason, and it creates no worktree. Test it:

bash
echo '{"hook_event_name":"WorktreeCreate","cwd":"/","name":"x"}' | ./worktree.py ; echo "exit $?"

install-drift.py ​

This hook warns when ~/.claude/ is behind the sources that harness install installs. Claude Code and Antigravity run it on the event SessionStart.

harness install --apply writes the stamp ~/.claude/.harness-install.json. The stamp holds the sources and a SHA-256 digest over the installed paths, their modes and the source bytes. The hook computes the same digest from the sources and compares the two. It warns when they differ, when the stamp is missing or cannot be read, and when a source cannot be read. The warning goes to the user and to the session, and it names the command to run. It does not run harness install, and an edit of the model tables does not count as drift.

It only warns: it blocks nothing and exits with status 0 for each input. Typical use says what to do when the warning appears.

prompt-log.py ​

This hook writes one JSON line for each permission dialog to ~/.claude/logs/permission-requests.jsonl. The environment variable LOG names another file. Claude Code runs it on the event PermissionRequest, for every tool, just before it shows the dialog.

Each line holds the time, the session, the working directory, the permission mode, the tool and its input. It cuts each string of the input to 400 characters, so a Write call does not log the whole file. The log shows which prompts a person still answers, by tool, by command and by mode. The transcript does not show this.

It decides nothing and prints nothing, so the dialog appears as it does without the hook. Every code path exits with status 0, also when the hook cannot read the input or write the log. Test it:

bash
echo '{"tool_name":"Bash","tool_input":{"command":"ls"}}' | LOG=$TMPDIR/p.jsonl ./prompt-log.py

context-handoff.py ​

This hook tells a main session that its context has passed 400k tokens, and that the session hands off. Each model call reads the whole context again, so a long session pays for its size on every call. Claude Code runs the hook on UserPromptSubmit and, for every tool, on PostToolUse.

The hook reads the size from the transcript that the event names: the input, cache-write and cache-read tokens of the last model call of the main chain. It reads the transcript from its end and stops at the second model call, so a transcript of 50 MB costs no more than a small one. A run takes about 0.1 s, nearly all of it the start of Python.

At 400k tokens or more, the hook adds one line to the session's context. The line gives the size and tells the session to finish the current step, write the state of the work into the task file or a hand-off note, propose a new session with a short brief, and stop. On UserPromptSubmit the line comes with every prompt. On PostToolUse it comes only after the model call that passes 400k, 500k, 600k and so on, so a long turn gets it once per 100k tokens, with each tool that this one model call runs. A call of a sub-agent gets no line: its caller limits its context. A record that Claude Code writes itself, such as an API error, has the model <synthetic> and counts as no model call.

It blocks nothing and exits with status 0 for each input, also when it cannot read the input or the transcript. Test it:

bash
echo '{"hook_event_name":"UserPromptSubmit","transcript_path":"'"$TMPDIR"'/t.jsonl"}' | ./context-handoff.py

The install gives this hook to Claude Code only. Antigravity's PreToolUse payload holds the call and the workspace paths, and no transcript; the guard extension of oh-my-pi and the plugins of OpenCode do not read the context size.

probe.py ​

This script holds the test cases of the hooks and runs them. Its GROUPS split the cases into seven groups, and harness test runs each group as the part probe:<group>, so the probe needs no run of its own; Development describes the run.

A case is a hook, an input and the expected result. The probe runs each case in its own process, with a timeout, and prints one line per case: ok or BAD, the expected and the actual result. The last line gives the number of cases and the number of failed cases, and the exit status is 1 when any case fails. The group commands runs the guard package on each record of tests/guard/corpus.jsonl and on the cases that a line of it cannot hold: a command of 1 MiB and one byte more, commands of 1 MiB that the checks walk word by word, each held to 10 s with its seconds on its line, the count of the file-system reads, walk steps and git processes of such commands, a write target with a NUL, a .git that is a dangling link, a link to itself, a payload that is not UTF-8, the reason of the winning check, a frontend that the guard lacks, a guard that raises, the copy of jsonio.py, the file-system reads outside paths.py, and the rendered Bash entry with a guard that cannot start. The groups omp and opencode run each oh-my-pi or OpenCode record of the corpus through the extension or the plugin and the guard package, and the cases of the transport that a record cannot hold: an interpreter that is missing, a guard that exits 3, prints {, is killed or answers late, a path list that is missing, malformed or without python, and a message that the guard cannot read. The group antigravity runs the guard package with --from antigravity from a fixture home, on each Antigravity record of the corpus and on the cases that a record cannot hold: a broken path list, a payload that cannot be read, a call with no workspace, commands of many words and of 1 MiB with their seconds, the count of the reads and spawns of commands of 1 MiB ([agy-work]), the count of the matcher calls of the Bash rules on a crafted command of 1 MiB ([mib-rules]), the rendered handler, and a check that raises. The probe also covers the other Python hooks of this page, the fail-closed cases of worktree.py, and the drift cases of install-drift.py. It also loads adapters/omp/guards.ts and the OpenCode plugins under node, so node must be on PATH. The hooks run against a fixture HOME in a temporary directory, never the real one. With no argument it runs every group; with group names, only those groups. Each run builds its own fixture:

bash
./probe.py [GROUP ...]