Skip to content

Runtime events: a slow handler can block the agent, and a handler can't tell which event fired #5336

Open

Description

@alfredoperez

What happens
Spec-kit's runtime events run an extension's script when something happens in the AI tool, for example when a turn ends. Two problems:

  1. A slow script can block the agent. If the script runs past its timeout, spec-kit's event runner exits with code 2. Claude Code reads exit code 2 from a "turn ended" hook as "don't stop, keep going", so one slow script can keep the agent running when it should stop.
  2. The script isn't told which event fired. It gets no arguments and no SPECKIT_* variables, only the AI tool's own data on stdin. A script that handles more than one event has to guess.

How to see it (v1.2.6)
A tiny extension with a "turn ended" (stop) event and a 2-second timeout. When its script takes 1 second, the hook exits 0. When it takes 5 seconds, the hook exits 2 and prints "timed out". The script is below.

Reproduction script
#!/usr/bin/env bash
# u3: when an extension's runtime-event handler runs past its timeout, spec-kit's event runner exits 2.
# For Claude Code, exit 2 from a Stop hook means "block": the agent is told to keep going.
# The handler below stands in for any slow call (a network request, a lock wait) with `sleep`.
# Everything else is stock: a minimal extension declaring a documented `stop` event with a 2 second timeout.
set -uo pipefail
REF="${REF:-v1.2.6}"
WORK="$(mktemp -d)"
cd "$WORK"
SPECIFY=(uvx -q --from "git+https://github.com/github/spec-kit.git@$REF" specify)
"${SPECIFY[@]}" init --here --force --non-interactive --integration claude --script sh --ignore-agent-tools >/dev/null 2>&1 || { echo "init failed"; exit 2; }
echo "spec-kit $REF in $WORK"

mkdir -p ext/commands ext/scripts/bash
cat > ext/extension.yml <<'YML'
schema_version: "1.0"
extension:
  id: slowstop
  name: "Slow stop handler"
  version: "0.1.0"
  description: "Records something when a turn ends"
  author: repro
  repository: https://example.com/slowstop
  license: MIT
requires:
  speckit_version: ">=1.2.0"
provides:
  commands:
    - name: speckit.slowstop.record
      file: commands/speckit.slowstop.record.md
      description: "Record the turn end"
events:
  stop:
    command: speckit.slowstop.record
    timeout: 2
YML
cat > ext/commands/speckit.slowstop.record.md <<'MD'
---
description: "Record the turn end"
scripts:
  sh: scripts/bash/record.sh
---
Runs on its own when a turn ends.
MD
printf '#!/usr/bin/env bash\nsleep 5\necho recorded\n' > ext/scripts/bash/record.sh
chmod +x ext/scripts/bash/record.sh
"${SPECIFY[@]}" extension add --dev ./ext </dev/null 2>&1 | tail -1

hook=$(python3 - <<'PY'
import json
s=json.load(open(".claude/settings.json"))
for group in s.get("hooks",{}).get("Stop",[]):
    for h in group.get("hooks",[]):
        if "slowstop" in h.get("command","") or "events" in h.get("command",""):
            print(h["command"]); raise SystemExit
PY
)
echo "Stop hook spec-kit wrote into .claude/settings.json:"
echo "  $hook"
[ -n "$hook" ] || { echo "no Stop hook found; not reproduced"; exit 1; }
echo '{"session_id":"repro","hook_event_name":"Stop","stop_hook_active":false}' > payload.json
run_hook() {
  start=$(date +%s)
  CLAUDE_PROJECT_DIR="$WORK" bash -c "$hook" < payload.json > out.txt 2> err.txt
  code=$?
  secs=$(( $(date +%s) - start ))
}
echo "A. the handler finishes in time (sleep 1, timeout 2)"
printf '#!/usr/bin/env bash\nsleep 1\necho recorded\n' > .specify/extensions/slowstop/scripts/bash/record.sh
run_hook; echo "   ran ${secs}s, exit $code, stderr: $(cat err.txt)"
okcode=$code
echo "B. the handler runs past its timeout (sleep 5, timeout 2)"
printf '#!/usr/bin/env bash\nsleep 5\necho recorded\n' > .specify/extensions/slowstop/scripts/bash/record.sh
run_hook; echo "   ran ${secs}s, exit $code, stderr: $(cat err.txt)"
if [ "$okcode" = "0" ] && [ "$code" = "2" ] && [ "$(awk '/timed out/{n++} END{print n+0}' err.txt)" != "0" ]; then
  echo "RESULT: spec-kit's runner timed the handler out and exited 2; Claude Code reads exit 2 from a Stop hook as a block"; exit 0
fi
echo "RESULT: not reproduced on $REF (in-time exit $okcode, timeout exit $code)"; exit 1

Suggested fix

  • When the runner itself times out or fails, exit 0 and print a note, so a failure never blocks the agent.
  • Pass the event name to the script, for example as SPECKIT_EVENT.
  • Document what arrives on stdin.

AI disclosure: drafted with Claude Code (Claude Opus 5.5, default settings) from runs in a throwaway sandbox; I checked it before filing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    triage-must-haveVerdict: high-value, important work for Spec Kit — do first

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions