I have opened my laptop in the morning to find a turn sitting exactly where it was the night before. No error. Nothing red. The turn simply had not ended.
As an indie developer running several repositories, hooks accumulate a few at a time, and every addition adds one more place that can quietly wait.
My instinct was to blame model latency or the network. I held that assumption for longer than I should have. What was actually holding everything up was a hook I had written myself: it handed a judgment off to an external service, and when that service stopped answering, the hook kept waiting.
IDE 2.6.0, released on August 7, changed how that kind of waiting is handled. Let's look at what moved, then build a way to find which of your own hooks can stall a turn, and finally give the hook its own cutoff.
Stalls come in three shapes
Lumping everything under "it hangs" hides the cause. Split by behavior, you get roughly three shapes.
| Symptom | What is happening | How visible |
|---|---|---|
| Turn never advances | A hook is still waiting for a response | Low — nothing reaches the log |
| Turn almost ends, then resumes | A Stop hook keeps refusing to let the turn finish | Medium — work repeats |
| Hook has no effect | It never fires, or fires later than expected | Low — it looks like success |
The first and third both stay quiet. One stalls, the other waves things through. The third is the more expensive one, because you find out afterward that something you meant to block went through. I wrote about the over-matching side of that in an audit of hook matchers that fire more often than intended. This piece stays with the stalling half.
What 2.6.0 and CLI 1.1.11 changed about waiting
IDE 2.6.0 and CLI 1.1.11 both shipped on August 7, and between them they cover most of this area.
- A hook that calls a model now stops with an explicit error at its configured timeout. Previously it could wait indefinitely
- A Stop hook that repeatedly refuses to end a turn no longer blocks forever; the turn completes after a bounded number of refusals
- Hook configurations that could never possibly run are now rejected with a clear error at load time instead of being silently ignored
- Custom hooks now run at the end of the turn rather than being skipped before firing
- Stopping a subagent now cascades to nested subagents and their background tasks
Read together, this release is less about new capability and more about failing loudly. The flip side matters: on anything older than 2.6.0, a hook without a timeout really can wait forever. Checking your version is the first move.
The hook schema and its key names are still an area under active change. Confirm the wording for your version in the Antigravity changelog before you copy a configuration. The examples below assume a timeoutSec key.
Finding which of your hooks can stall a turn
Reading the config file rarely reveals the risky entries. A line missing timeoutSec is missing it because, at the time, you judged the work to be short. Whether that judgment still holds is not something you can see by looking.
So classify it mechanically instead. The script below checks three things: whether a timeout is configured, whether the script the hook invokes carries its own cutoff, and whether that script can actually start.
#!/usr/bin/env python3
"""Read a hooks config and surface the hooks that can stall a turn."""
import json, os, re, stat, sys
SELF_GUARD = re.compile(r"\btimeout\b|\bSIGALRM\b|signal\.alarm|AbortSignal\.timeout")
STALL_EVENTS = {"PreToolUse", "PostToolUse", "Stop", "SubagentStop", "UserPromptSubmit"}
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f).get("hooks", {})
def script_of(command):
"""Return the path if the first token of command is one of our scripts."""
token = command.split()[0] if command.split() else ""
return token if token.endswith((".sh", ".py", ".js", ".mjs")) else ""
def audit(path, default_limit=60):
rows = []
for event, entries in load(path).items():
for i, h in enumerate(entries):
cmd = h.get("command", "")
limit = h.get("timeoutSec")
script = script_of(cmd)
exists = bool(script) and os.path.exists(script)
execbit = exists and bool(os.stat(script).st_mode & stat.S_IXUSR)
guarded = False
if exists:
body = open(script, encoding="utf-8", errors="replace").read()
guarded = bool(SELF_GUARD.search(body))
if limit is None and not guarded:
risk = "HIGH" # nobody owns an upper bound
elif limit is None:
risk = "MED" # only the script owns one
elif limit > default_limit and not guarded:
risk = "MED" # the bound exists but is generous
else:
risk = "LOW"
# A hook that cannot start is a different problem from a slow one
if script and not exists:
risk = "BROKEN"
elif exists and not execbit:
risk = "BROKEN"
rows.append({
"event": event, "risk": risk, "timeoutSec": limit,
"selfGuard": guarded, "command": cmd,
"blocksTurn": event in STALL_EVENTS,
})
return rows
def main():
path = sys.argv[1] if len(sys.argv) > 1 else "hooks.json"
rows = audit(path)
order = {"BROKEN": 0, "HIGH": 1, "MED": 2, "LOW": 3}
rows.sort(key=lambda r: (order[r["risk"]], r["event"]))
print(f"{'risk':<7}{'event':<16}{'timeoutSec':<12}{'selfGuard':<11}command")
for r in rows:
t = "-" if r["timeoutSec"] is None else str(r["timeoutSec"])
print(f"{r['risk']:<7}{r['event']:<16}{t:<12}{str(r['selfGuard']):<11}{r['command']}")
bad = sum(1 for r in rows if r["risk"] in ("BROKEN", "HIGH"))
print(f"\n{bad} of {len(rows)} hooks can stall a turn.")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())SELF_GUARD looks for timeout or signal.alarm inside the script body because the configured timeoutSec and the script's own cutoff live in different layers. Either one is enough to prevent an unbounded wait, so only entries missing both drop to HIGH.
Run against a config holding four hooks:
risk event timeoutSec selfGuard command
BROKEN PostToolUse 30 False ./fmt.sh
HIGH PreToolUse - False ./review.sh
MED Stop 120 False ./review.sh
LOW PreToolUse 600 True ./gate.sh
2 of 4 hooks can stall a turn.
The BROKEN row has a timeoutSec but had lost its execute bit. A hook in that state never gets as far as waiting, so from the outside it looks fast. It is not fast. It is absent.
Put the cutoff in both layers
There are two reasons not to lean on the configured timeoutSec alone. Version-to-version, how strictly it is honored is still moving. And if the same script is also invoked from CI or a local git hook, that path never sees the configured bound at all.
Here is the script-side version.
#!/usr/bin/env bash
# A slow judgment the hook itself can abandon
set -u
LIMIT="${HOOK_LIMIT_SEC:-3}"
timeout --signal=TERM --kill-after=2 "$LIMIT" bash -c 'sleep 8; echo "judge: ok" >&2'
RC=$? # captured immediately, not wrapped in an if
case "$RC" in
0) exit 0 ;;
124) echo "hook: no judgment within ${LIMIT}s. Skipping the check this time." >&2; exit 0 ;;
*) echo "hook: the judgment failed with exit code ${RC}." >&2; exit "$RC" ;;
esac--kill-after=2 is the grace period before KILL follows TERM. Without it, a child that ignores TERM can defeat the cutoff you just added.
Running an eight-second job under different limits, the wall time tracks the limit closely.
| Limit | Actual elapsed | Exit code |
|---|---|---|
| 1s | 1.01s | 0 (skipped) |
| 3s | 3.01s | 0 (skipped) |
| 5s | 5.01s | 0 (skipped) |
| no limit | 8.01s | 0 (completed) |
Mapping 124 to exit 0 is a deliberate choice. "The judgment did not come back" and "the judgment said no" are different facts. Blocking the tool call on the former means one flaky external service can stop the agent from moving at all. For a gate protecting an irreversible action, the same 124 should probably become exit 1 instead — I wrote about that stricter posture in a two-layer gate placed in front of push. Identical exit code, opposite answer, depending on what you are protecting.
Wrapping it in if makes 124 disappear
There is a reason RC=$? sits outside the if above. My first version read the obvious way:
if timeout --signal=TERM "$LIMIT" bash -c '...'; then
exit 0
fi
RC=$?It reads fine. It also never puts 124 into RC.
if timeout 1 sleep 5; then :; fi
echo "after the if block, \$? = $?" # → 0
timeout 1 sleep 5
echo "captured directly, \$? = $?" # → 124An if statement whose condition is false and which has no else exits with 0. So the $? you read after fi is the status of the if statement, not of the command inside it. A guard written specifically to detect a cutoff was swallowing the cutoff and reporting success.
A hook's exit code is its reply to the agent. Returning 0 there tells the agent that a check which timed out has passed. Adding the guard had quietly added one more path straight through.
The same thing happens with if ! cmd; then RC=$?. Negating the command resets $? to 0. If you need the exit code, capture it on the line right after the command — there is no reliable alternative.
Work the triage in this order
Fixing the order you investigate in saves the deliberation each time.
- Check the version. Anything before 2.6.0 can wait indefinitely when no timeout is set
- Run the audit script and clear the
HIGHandBROKENrows first. This often removes half the candidates - Run each remaining suspect straight from the command line, outside the agent. If it does not return by hand, the problem is inside the hook
- If it returns standalone but stalls inside a turn, suspect a Stop hook refusal loop. Look for the same work repeating
- Only once those come back clean should you start suspecting the model or the network
The point of the order is to clear the quiet failures first. Steps 1 and 2 take a few minutes and need no reproduction.
Where to start
Run the audit script over your config once. If no HIGH row appears, you have at least eliminated the unbounded-wait path.
I ran that if-wrapped guard for a while myself. Mistakes of this shape survive precisely because the person who wrote them believes the situation is now handled. It may be worth checking whether the same pattern is sitting in your own hooks.