Don't Ask the Agent, Enforce It with Hooks: What I Took from Reading oh-my-openagent's Code
A harness that grew to 57 hooks, one per failure mode, and how the hooks started colliding until every message had to go through one gate
Short version: when you spot where your agent breaks, don't add another line to the prompt. Put a hook (code that runs at a fixed moment) at that spot. After this post you'll know which hooks the OpenCode plugin oh-my-openagent (OmO) actually installs, and you'll have a working example of the same idea in Claude Code. I also cover what goes wrong when the hooks pile up.
OmO calls itself a batteries-included agent harness. A harness is the outer shell that wraps a model with tools, rules and retries. That sounds nice, but I wanted to see what the code does, so I cloned the repo and read it. Everything here is based on the dev branch as of 2026-11-05 (commit 48ed2357d, version 5.1.60). The package is still named oh-my-opencode.
Scale first: 44 releases in a month
Counted with git ls-files and wc -l.
workspaces (incl. per-platform binaries) 68
TypeScript (excluding tests) ~490k lines
Rust ~86k lines
Test files 4,139
Hooks named in the config schema 57
Release tags v5.1.17 (10-05) → v5.1.60 (11-04), 44
It doesn't fork OpenCode. It plugs into the hook points OpenCode exposes (config load, message received, before and after tool calls, session events, compaction) and changes behavior there. That makes it easy to keep up with OpenCode releases. The flip side: anything without a hook point is out of reach.
One hook per failure
Reading the hook list, each name is a record of how an agent once broke. A few picks:
| How it breaks | Hook | What it does |
|---|---|---|
| Edits a stale line | hashline-read-enhancer + hashline-core |
Tags every line of Read output with a hash, rejects the edit if the hash differs |
| Stops with todos left | todo-continuation-enforcer |
When the session goes idle with todos left, injects continue |
| Planner edits code | prometheus-md-only |
The planning agent may only edit .md |
| Overwrites an existing file wholesale | write-existing-file-guard |
Blocks Write over an existing file |
| Doesn't know folder rules | directory-agents-injector, rules-injector |
Injects that path's AGENTS.md and rules automatically |
| Context overflows | preemptive-compaction, compaction-todo-preserver |
Compacts before the limit and keeps the todos |
Hashline: a two-letter hash next to each line number
An agent reads a file, then edits from memory a few turns later. If the file changed in between, it edits the wrong line. OmO tags each line of Read output as line#hash|. The hash is the line's xxHash32 mod 256, written as two letters from the 16-letter set ZPMQVRWSNKTXJBYH. I ran the repo's implementation with bun:
1#KW|def total(items)
2#KQ| items.sum(&:price)
3#PT|end
# change only line 2
1#KW|def total(items)
2#YS| items.sum(&:amount)
3#PT|end
Only the changed line gets a new hash. If the agent asks to edit 2#KQ, line 2 is now YS, so it gets a HashlineMismatchError carrying the new hash and has to re-read. Two letters means only 256 values, so collisions happen. That's why it's always paired with the line number.
Claude Code's Edit only applies when old_string matches the file character for character. It locates the change by original text instead of line number, so it blocks the same problem a different way. If you're building a tool that edits by line number, Hashline is the one to study.
Forcing todos to continue: code that knows when not to push
When the session goes idle with todos left, todo-continuation-enforcer waits through a 2-second countdown and injects a message starting with Incomplete tasks remain in your todo list. If the agent claims it's done, there's even a line saying the system is questioning that claim and it should check again.
The interesting part is how much more code decides not to push. The idle handler has 25 early returns. It holds off if the user just aborted (3 seconds), if background tasks are running, if a question to the user is pending, if the token limit was hit, if compaction just happened, if it already pushed within 5 seconds, after 5 failures in a row, or after 3 stops in the same state. I assumed the whole thing was inject continue and done. In practice the bulk of it prevents infinite loops and steamrolling the user.
The twist: more hooks, more collisions
Issue #4012, filed in May 2026, reported two assistant bubbles streaming the same text at once. No single hook was at fault. OpenCode's session.promptAsync returns before the message is durably accepted. So two hooks that saw the same idle (say, todo continuation and a background task completion notice) each decided it was safe to inject, and both did.
The fix is the part most worth stealing. According to the repo's ADR, more than 13 places injected messages, and per-route care couldn't close the race. So only one file, prompt-async-gate.ts, may call session.prompt or promptAsync. Everything else goes through dispatchInternalPrompt(), which takes a reservation per session ID and lets only the first claimant send. Break that rule and a test that reads the source files (prompt-async-route-audit.test.ts) fails.
Enforcing with hooks is still a set of rules, and enough rules need their own enforcement. Just as they stopped asking the agent, they stopped asking hook authors to be careful and wrote a test instead.
Other things I tripped over: to keep agents in order it swaps out the global Array.prototype.sort and toSorted (agent-sort-shim.ts). It recommends 200 lines per file, yet 629 files go over, and the background task manager manager.ts is 3,416 lines. Anonymous telemetry is on by default according to the docs and turns off with DO_NOT_TRACK=1 (I didn't install it to confirm).
Doing it in Claude Code: one Stop hook
You don't need all of OmO to borrow the idea. A Claude Code Stop hook runs when the agent is about to finish its reply. Print JSON with decision: "block" and a reason, and instead of stopping it takes the reason and keeps working.
#!/usr/bin/env bash
# .claude/hooks/todo-gate.sh — push once more if TODO.md still has [ ]
input=$(cat)
# already continuing because of this hook: let it stop (no infinite loop)
[ "$(printf '%s' "$input" | jq -r .stop_hook_active)" = true ] && exit 0
left=$(grep -c '^- \[ \]' TODO.md 2>/dev/null || true)
if [ "${left:-0}" -gt 0 ]; then
jq -n --arg n "$left" '{decision:"block", reason:("Unfinished items in TODO.md: " + $n + ". Continue with the next one")}'
fi
Register it as a command under hooks.Stop in .claude/settings.json. Feeding it fake input, it prints a block when two items are left, and prints nothing when stop_hook_active is true or everything is checked. The official docs also say to check stop_hook_active so you don't keep blocking on a condition that never resolves, and continuations are capped at 8 in a row.
This script pushes only once. To do OmO's give up after 3 stops in the same state, write the remaining count to a file and compare whether it went down. How far to push is the entire design of this hook.
This blog went down the same road. If a post gets added after __END__ in the controller, it never shows up on the web, and writing be careful in the rules doc a few times still didn't stop the occasional miss. Now a PostToolUse hook runs after Edit and Write, checks syntax and placement, and hands problems straight back to the agent. Since then nobody has had to catch that mistake in review.
When to use the prompt, when to use a hook
| Write it in the prompt | Enforce it with a hook | |
|---|---|---|
| Good fit | Taste, style, anything needing judgment | Anything a machine can check true or false (file state, paths, remaining todos) |
| When broken | A human catches it in review | Rejected on the spot with a reason |
| Cost | One line | A script plus loop and false-positive handling |
| As it grows | Longer prompt, less compliance | Hooks collide, so funnel through one gate |
There's also a post covering harness engineering vs prompt engineering as concepts. This one looks at that difference in real code.
OmO is better read as a textbook than adopted wholesale. Skimming the hook names alone gives you a map of where agents break. If your team keeps hitting the same agent mistakes and can't decide what to move into hooks, you can ask negabaro. We'll go through your current rules docs together and pick the lines worth turning into code first.
Key Concepts
Move rules a machine can check true or false (file state, paths, remaining todos) from the prompt into hooks first
For a hook that keeps pushing, write the don't-push conditions first (just aborted, waiting on a question, same state again)
In a Claude Code Stop hook, check stop_hook_active so you never keep blocking on a condition that won't resolve
When several hooks inject messages, funnel them through one gate and block bypasses with a test