Files
ai-agent/backend/hooks.py
Gabriel Vidal f9b1e40eae fix(hooks): cap post-task hook's context so it can't quietly re-pay the whole conversation
A resumed hook firing is a brand-new claude -p process, and Claude Code
rebuilds that process's environment preamble from scratch — any drift there
(near-certain on a shared checkout) invalidates the finished run's prompt
cache entirely. Sampled real transcripts confirm every firing re-creates the
whole prior conversation as fresh input tokens rather than reading it from
cache, regardless of delay. maxContextK was defaulted to 0 (no cap), so an
unbounded conversation meant an unbounded tidy-up bill. Default it to 60k and
document why, in both the backend docstring and CLAUDE.md.
2026-08-09 20:44:00 +02:00

445 lines
20 KiB
Python

"""Conversation hooks: follow-up prompts that fire after a conversation finishes.
A hook is the post-task twin of a cron job (``cron.py``): instead of a schedule
it has an **event** (``conversation-done``) and a **delay**, and instead of
spawning a *new* session it **resumes the conversation that just finished** with
the content of its prompt file. The flagship use is the seeded ``post-task``
hook — ten seconds after an agent says ``DONE``, the same session is woken up to
save memories, update the README/CLAUDE.md, copy artefacts into
``.ai/artefacts/``, tear down worktrees and update the changelog.
Storage is a single JSON sidecar (``/data/hooks.json``) holding both the hooks
and the **pending queue**, so a firing survives a backend restart (or the
blue-green cutover where two backends briefly share ``/data``):
{ "hooks": { "<hookId>": {
"id": "post-task",
"name": "Post-task follow-up",
"event": "conversation-done",
"delaySeconds": 10,
"promptFile": ".claude/agents/hooks/post-task.md",
"enabled": true,
"skipCron": true, # don't follow up cron-spawned sessions
"skipSubagents": true, # …nor subagent (sidechain) conversations
"maxContextK": 60, # 0 = no transcript-size cap
"createdAt": iso,
"lastStatus": "ok" | "error: …" | "skipped: …",
"history": { iso: sessionId, … },
} },
"pending": [ {"hookId": …, "sessionId": …, "dueAt": iso, "retries": 0}, … ]
}
**A resume does not reuse the finished run's prompt cache — budget for that.**
``--resume`` really does append to the same session (verified against real
transcripts: same ``sessionId``, same ``.jsonl``, ``meta.hooks`` stamped once).
But it does so from a **brand-new OS process** (the sidecar shells out to a
fresh ``claude -p --resume <sid>``), and Claude Code rebuilds that process's
environment preamble (cwd, git status, date, …) from scratch. Anthropic's
prompt cache is a byte-exact prefix match, so any difference there — near
certain on a shared checkout with other agents committing in parallel, and
plausible even in a quiet worktree — invalidates every cache breakpoint after
it. In practice this means a firing pays to **re-create the entire prior
conversation as fresh input tokens**, not to read it from cache: sampled
firings on real transcripts showed ``cache_read_input_tokens`` collapse to the
small cross-session baseline and ``cache_creation_input_tokens`` balloon to
the full prior total, every single time, regardless of the (10s+) delay. That
cost scales with the *original* conversation's size, so an unbounded
``maxContextK`` means a two-line tidy-up pass can silently cost as much as the
feature work it's following up on. Set ``maxContextK`` to whatever context
size makes that trade worth it — 0 (no cap) is rarely what you want.
**The loop guard is the point.** The follow-up run ends with ``COMPLETED``
(not ``DONE`` — a distinct marker so the viewer can tell "the task finished"
apart from "the task finished AND was tidied up after") and also exits, which
enqueues the hook again. Each firing therefore stamps
``meta.hooks[hookId] = iso`` on the session (``meta.py`` merges that mapping
rather than replacing it), and both the enqueue and the fire-time preconditions
refuse an already-stamped (hook, session) pair — **once per hook per session**,
full stop.
The scheduler is a daemon thread waking every ~10s. A due entry is *claimed*
(removed from the queue, atomically, re-reading the file first) before it fires,
so a parallel backend can't double-resume; a precondition that is merely not-yet
(the run is still live, the transcript hasn't synced) re-enqueues the entry a
minute later, up to ``MAX_RETRIES``.
"""
import json
import os
import pathlib
import threading
import time
from datetime import datetime, timedelta
import cron as cron_mod
# Prompt files live in their own subdirectory of the cron prompt dir, so a hook
# prompt is never mistaken for a schedulable agent and vice versa.
PROMPT_DIR = f"{cron_mod.PROMPT_DIR}/hooks"
# The only event v1 fires on. Kept as a field (and validated) so adding
# "conversation-started" or "commit" later doesn't change the stored shape.
EVENTS = ("conversation-done",)
# A precondition that may still come true (run still live, transcript not
# synced yet) postpones the entry by this many seconds, this many times.
RETRY_DELAY_S = 60
MAX_RETRIES = 10
# Re-exported so main.py has one import for the prompt-file helpers.
strip_frontmatter = cron_mod.strip_frontmatter
run_config = cron_mod.run_config
frontmatter_block = cron_mod.frontmatter_block
slugify = cron_mod.slugify
def _now_iso() -> str:
return datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S%z")
def _parse_iso(s: str) -> datetime | None:
try:
dt = datetime.fromisoformat(s)
except (TypeError, ValueError):
return None
return dt if dt.tzinfo else dt.astimezone()
def valid_prompt_file(rel: str) -> str:
"""Normalize + validate a hook's prompt-file path: a ``.md`` directly under
``.claude/agents/hooks/``. Raises ValueError otherwise."""
rel = (rel or "").strip().lstrip("/")
p = pathlib.PurePosixPath(rel)
if ".." in p.parts:
raise ValueError("promptFile must not contain '..'")
if p.suffix.lower() != ".md":
raise ValueError("promptFile must be a .md file")
if str(p.parent) != PROMPT_DIR:
raise ValueError(f"promptFile must live in {PROMPT_DIR}/")
return str(p)
def valid_event(event: str) -> str:
ev = (event or "").strip() or EVENTS[0]
if ev not in EVENTS:
raise ValueError(f"unknown event {ev!r}")
return ev
def valid_delay(v) -> int:
try:
d = int(v)
except (TypeError, ValueError):
raise ValueError("delaySeconds must be a whole number of seconds")
if not 0 <= d <= 24 * 60 * 60:
raise ValueError("delaySeconds must be between 0 and 86400")
return d
# ── the seeded hook ──────────────────────────────────────────────────────────
# Enabled on first run: every finished task gets the follow-up pass. Disable it
# in Settings → Hooks if a stretch of work shouldn't pay for one.
DEFAULT_HOOKS = {
"post-task": {
"id": "post-task",
"name": "Post-task follow-up",
"event": "conversation-done",
"delaySeconds": 10,
"promptFile": f"{PROMPT_DIR}/post-task.md",
"enabled": True,
"skipCron": True,
"skipSubagents": True,
# A resume can't reuse the finished run's prompt cache (new OS process
# ⇒ new environment preamble ⇒ cache-prefix mismatch — see the module
# docstring), so an unbounded cap would silently re-pay the whole
# conversation's cost for every tidy-up pass. 60k keeps the follow-up
# itself cheap; bigger conversations just skip it (a normal outcome).
"maxContextK": 60,
"createdAt": None, # stamped at seed time
"lastStatus": None,
"history": {},
},
}
# The prompt the seeded hook resumes a finished conversation with. Written to
# the workspace on first run (only if the file doesn't exist), so a fresh
# install has a working post-task pass without any setup.
DEFAULT_POST_TASK_PROMPT = """---
name: post-task
description: Follow-up pass ten seconds after a task finishes — save memories, update docs, collect artefacts, clean up worktrees and processes, update the changelog.
---
Follow-up pass on the task you just finished. Work through this list, do only
what genuinely applies, and keep it short — this is tidying, not new work.
1. **Memory.** Was anything here non-obvious — a gotcha, a wrong turn, a
constraint that isn't visible in the code? Save it to your memory directory
(one fact per file, with frontmatter) and add its line to `MEMORY.md`. Skip
anything the repo already records.
2. **Docs.** If the change altered how something is used, built or deployed,
update the affected `README.md` / `CLAUDE.md` / `GOAL.md`. No changelog-style
prose in CLAUDE.md — describe the current state.
3. **Artefacts.** Copy anything worth keeping (screenshots, diagrams, generated
samples, reports) into the canonical project's `.ai/artefacts/<sessionId>/`
folder so it renders inline in the conversation viewer. Leave scratch files
behind.
4. **Cleanup.** Remove worktrees you created once their branch is merged
(`scripts/new-worktree.sh -r <path>`), and kill background processes, dev
servers or standby containers you started and no longer need.
5. **Changelog.** If the project keeps one, add the user-facing entry for what
shipped.
Do **not** re-notify (no `notify-done`) and do not start new feature work. If
nothing on the list applies, say so in one line.
End your reply with `COMPLETED` on its own line (not `DONE` — this pass has its
own marker so the viewer can show that the task was tidied up, not just finished).
"""
class HookStore:
"""The JSON store — hooks + the persistent pending queue.
Same discipline as ``CronStore``/``MetaStore``: one lock, atomic saves and
an mtime-based reload so the other container during a deploy cutover (or an
out-of-band edit) is picked up before any decision is taken."""
def __init__(self, path: str):
self.path = pathlib.Path(path)
self._lock = threading.Lock()
self._mtime: float | None = None
self._hooks: dict[str, dict] = {}
self._pending: list[dict] = []
self._load()
if self._mtime is None: # no file yet → seed the defaults
with self._lock:
self._hooks = json.loads(json.dumps(DEFAULT_HOOKS))
for h in self._hooks.values():
h["createdAt"] = _now_iso()
self._pending = []
self._save()
# ── persistence ──────────────────────────────────────────────────────────
def _load(self) -> None:
try:
raw = json.loads(self.path.read_text(encoding="utf-8")) or {}
self._hooks = raw.get("hooks") or {}
self._pending = raw.get("pending") or []
self._mtime = self.path.stat().st_mtime
self._migrate_delay_unit()
except (OSError, ValueError):
self._hooks = {}
self._pending = []
self._mtime = None
def _migrate_delay_unit(self) -> None:
"""Pre-existing stores keep the old ``delayMinutes`` field. Convert it
in place (minutes → seconds) on load so an already-configured hook's
delay survives the rename instead of silently resetting to 0."""
for hook in self._hooks.values():
if "delaySeconds" not in hook and "delayMinutes" in hook:
hook["delaySeconds"] = int(hook.pop("delayMinutes") or 0) * 60
def _save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".json.tmp")
tmp.write_text(json.dumps({"hooks": self._hooks,
"pending": self._pending},
indent=2, sort_keys=True), encoding="utf-8")
os.replace(tmp, self.path)
try:
self._mtime = self.path.stat().st_mtime
except OSError:
pass
def _reload_if_changed(self) -> None:
try:
m = self.path.stat().st_mtime
except OSError:
return
if m != self._mtime:
self._load()
# ── hooks CRUD ───────────────────────────────────────────────────────────
def list(self) -> "list[dict]":
with self._lock:
self._reload_if_changed()
return json.loads(json.dumps(list(self._hooks.values())))
def get(self, hook_id: str) -> dict | None:
with self._lock:
self._reload_if_changed()
h = self._hooks.get(hook_id)
return json.loads(json.dumps(h)) if h else None
def create(self, name: str, *, event: str = EVENTS[0],
delay_seconds: int = 10, prompt_file: str,
enabled: bool = True, skip_cron: bool = True,
skip_subagents: bool = True, max_context_k: int = 0) -> dict:
event = valid_event(event)
delay_seconds = valid_delay(delay_seconds)
prompt_file = valid_prompt_file(prompt_file)
with self._lock:
self._reload_if_changed()
base = slugify(name)
hook_id, n = base, 2
while hook_id in self._hooks:
hook_id, n = f"{base}-{n}", n + 1
hook = {"id": hook_id, "name": name or hook_id, "event": event,
"delaySeconds": delay_seconds, "promptFile": prompt_file,
"enabled": bool(enabled), "skipCron": bool(skip_cron),
"skipSubagents": bool(skip_subagents),
"maxContextK": max(0, int(max_context_k or 0)),
"createdAt": _now_iso(), "lastStatus": None, "history": {}}
self._hooks[hook_id] = hook
self._save()
return json.loads(json.dumps(hook))
def update(self, hook_id: str, patch: dict) -> dict | None:
if patch.get("event") is not None:
patch["event"] = valid_event(patch["event"])
if patch.get("delaySeconds") is not None:
patch["delaySeconds"] = valid_delay(patch["delaySeconds"])
if patch.get("promptFile") is not None:
patch["promptFile"] = valid_prompt_file(patch["promptFile"])
if patch.get("maxContextK") is not None:
patch["maxContextK"] = max(0, int(patch["maxContextK"]))
with self._lock:
self._reload_if_changed()
hook = self._hooks.get(hook_id)
if not hook:
return None
for k in ("name", "event", "delaySeconds", "promptFile", "enabled",
"skipCron", "skipSubagents", "maxContextK"):
if patch.get(k) is not None:
hook[k] = patch[k]
self._save()
return json.loads(json.dumps(hook))
def delete(self, hook_id: str) -> bool:
with self._lock:
self._reload_if_changed()
if hook_id not in self._hooks:
return False
del self._hooks[hook_id]
# Drop anything queued for it — a deleted hook must not fire.
self._pending = [p for p in self._pending
if p.get("hookId") != hook_id]
self._save()
return True
def record_run(self, hook_id: str, session_id: str,
status: str = "ok") -> None:
"""Append ``{now: sessionId}`` to the hook's history and stamp the
outcome (what the card's status chip shows)."""
with self._lock:
self._reload_if_changed()
hook = self._hooks.get(hook_id)
if not hook:
return
if session_id:
hist = dict(hook.get("history") or {})
hist[_now_iso()] = session_id
if len(hist) > 200: # keep the most recent 200 firings
for k in sorted(hist)[:len(hist) - 200]:
del hist[k]
hook["history"] = hist
hook["lastStatus"] = status
self._save()
# ── the pending queue ────────────────────────────────────────────────────
# NB: annotations below are strings — the ``list`` method above shadows the
# builtin inside the class body, so a bare ``list[dict]`` would evaluate
# against it and raise at import time.
def pending(self) -> "list[dict]":
with self._lock:
self._reload_if_changed()
return json.loads(json.dumps(self._pending))
def is_pending(self, hook_id: str, session_id: str) -> bool:
with self._lock:
self._reload_if_changed()
return any(p.get("hookId") == hook_id
and p.get("sessionId") == session_id
for p in self._pending)
def enqueue(self, hook_id: str, session_id: str, delay_seconds: int,
retries: int = 0) -> dict | None:
"""Queue one firing, due ``delay_seconds`` from now. Returns the entry,
or None when this (hook, session) pair is already queued — the caller
checks the ``meta.hooks`` stamp for pairs that already *fired*."""
due = (datetime.now().astimezone()
+ timedelta(seconds=max(0, delay_seconds)))
with self._lock:
self._reload_if_changed()
if any(p.get("hookId") == hook_id
and p.get("sessionId") == session_id
for p in self._pending):
return None
entry = {"hookId": hook_id, "sessionId": session_id,
"dueAt": due.strftime("%Y-%m-%dT%H:%M:%S%z"),
"queuedAt": _now_iso(), "retries": int(retries)}
self._pending.append(entry)
self._save()
return json.loads(json.dumps(entry))
def claim(self, hook_id: str, session_id: str) -> bool:
"""Atomically take this entry off the queue. False = someone else got
it first (the other backend during a deploy cutover)."""
with self._lock:
self._reload_if_changed()
keep = [p for p in self._pending
if not (p.get("hookId") == hook_id
and p.get("sessionId") == session_id)]
if len(keep) == len(self._pending):
return False
self._pending = keep
self._save()
return True
def due_entries(self, now: datetime | None = None) -> "list[dict]":
"""Queued firings whose ``dueAt`` has passed (an unparseable one is
treated as due, so a corrupt stamp can't strand an entry forever)."""
now = now or datetime.now().astimezone()
out = []
for p in self.pending():
due = _parse_iso(p.get("dueAt") or "")
if due is None or due <= now:
out.append(p)
return out
class HookScheduler(threading.Thread):
"""Fires due queue entries. ``fire(hook, sessionId, entry)`` is injected by
main.py (it needs the resume path, the summaries and the meta store)."""
def __init__(self, store: HookStore, fire, interval: float = 10.0):
super().__init__(daemon=True, name="hook-scheduler")
self.store = store
self.fire = fire
self.interval = interval
def run(self) -> None:
while True:
time.sleep(self.interval)
try:
self._tick()
except Exception:
pass
def _tick(self) -> None:
for entry in self.store.due_entries():
hook_id = entry.get("hookId") or ""
sid = entry.get("sessionId") or ""
hook = self.store.get(hook_id)
if not hook or not hook.get("enabled"):
# Deleted or switched off while queued: drop it silently.
self.store.claim(hook_id, sid)
continue
if not self.store.claim(hook_id, sid):
continue # a parallel backend took it
try:
self.fire(hook, sid, entry)
except Exception as e: # never kill the loop on one bad firing
self.store.record_run(hook_id, "", status=f"error: {e}")