Model Citizen by Jake Selby v0.15.0
GitHub
HookPreToolUse

session-caps

Hold a session to its fan-out cap and its web-search cap: count both, warn near, deny at.

Event
PreToolUse
Runs
always
Dispatcher
adapters/claude-code/hook.py
Script
policy/hooks/session-caps.py

What it does

Two caps, each read from the text that states it, so a stance change moves the number:

fan-out the cost variant's switches.max_parallel (balanced 6, frugal 3, max none) web search the research rule's "capped per session — N calls" (200 as shipped); none while that rule is switched off

**Counting.** Every hook is a process of its own and several run at once, so a session's counts live in one append-only journal, caps/<session>.jsonl under the harness state directory, and every count is recomputed from it. A subagent is live from its SubagentStart to its SubagentStop, and a start with no stop decays after RUNNING_TTL, as the usage feed's does. One message can launch many spawns before any of them has started, so an allowed spawn is also a reservation that counts as live until a start consumes it or RESERVE_TTL passes. A search is counted when it is allowed. A subagent's own tool calls carry its parent's session_id, so its searches count against the parent session.

**A blocked stop.** Another SubagentStop hook can block the stop, and the subagent then runs on. No hook can see that outcome: matching hooks run in parallel and no event reports a block. So every stop is journalled, the one raised after a block (stop_hook_active) included, with the size of the subagent's transcript at that moment, and before a spawn is decided a stopped subagent whose transcript has since gained a turn timestamped after the stop counts as live again until its next stop. Claude Code writes the block's reason into that transcript as soon as the stop hooks return, so the count runs low only while the blocking hook itself runs. A strictly conservative count, live until a stop known to be final, was rejected: only a foreground Agent return marks a final stop, and subagents run in the background by default, so most would count as live until RUNNING_TTL.

**Deciding.** A spawn when the live count is already at the cap is denied, and so is a search when the session has made as many as the cap allows; the reason names the cap and the count. Past 80% of a cap the call goes through with a warning, said on every spawn and once a session for searches. A Workflow launch is denied at the fan-out cap too, but its agent() calls are not tool calls and cannot be refused one by one: they count only as far as the runtime raises SubagentStart for them. Every decision is a session-caps row in the decision log.

**Headless.** A claude -p run (CLAUDE_CODE_ENTRYPOINT=sdk-cli), which is what a benchmark replay is, never has a spawn or a search denied: the row says would-deny and the call goes through with a warning, so a replay measures the arm and not this guard. HARNESS_SESSION_CAPS_HEADLESS=enforce turns the denial back on for a test that wants it.

Claude Code only: Codex raises no subagent lifecycle events, so a live count there could not fall. A guard that cannot read its state lets the call through; it never denies by accident.

When it runs

The policy answers on PreToolUse. It runs in every install. It has no settings entry of its own: harness sync registers one dispatcher command per event, adapters/claude-code/hook.py, marked harness:runtime-pretooluse in your settings.json. That dispatcher routes each call to the policies its event and tool need and composes their answers; session-caps is the policy's id in OWNERSHIP.json.

The script

policy/hooks/session-caps.pypython
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Hold a session to its fan-out cap and its web-search cap: count both, warn near, deny at.

Two caps, each read from the text that states it, so a stance change moves the number:

    fan-out      the cost variant's `switches.max_parallel` (balanced 6, frugal 3, max none)
    web search   the research rule's "capped per session — N calls" (200 as shipped); none
                 while that rule is switched off

**Counting.** Every hook is a process of its own and several run at once, so a session's counts
live in one append-only journal, `caps/<session>.jsonl` under the harness state directory, and
every count is recomputed from it. A subagent is live from its `SubagentStart` to its
`SubagentStop`, and a start with no stop decays after `RUNNING_TTL`, as the usage feed's does.
One message can launch many spawns before any of them has started, so an allowed spawn is also a
reservation that counts as live until a start consumes it or `RESERVE_TTL` passes. A search is
counted when it is allowed. A subagent's own tool calls carry its parent's `session_id`, so its
searches count against the parent session.

**A blocked stop.** Another `SubagentStop` hook can block the stop, and the subagent then runs
on. No hook can see that outcome: matching hooks run in parallel and no event reports a block.
So every stop is journalled, the one raised after a block (`stop_hook_active`) included, with
the size of the subagent's transcript at that moment, and before a spawn is decided a stopped
subagent whose transcript has since gained a turn timestamped after the stop counts as live
again until its next stop. Claude Code writes the block's reason into that transcript as soon
as the stop hooks return, so the count runs low only while the blocking hook itself runs. A
strictly conservative count, live until a stop known to be final, was rejected: only a
foreground `Agent` return marks a final stop, and subagents run in the background by default,
so most would count as live until `RUNNING_TTL`.

**Deciding.** A spawn when the live count is already at the cap is denied, and so is a search
when the session has made as many as the cap allows; the reason names the cap and the count.
Past 80% of a cap the call goes through with a warning, said on every spawn and once a session
for searches. A `Workflow` launch is denied at the fan-out cap too, but its `agent()` calls are
not tool calls and cannot be refused one by one: they count only as far as the runtime raises
`SubagentStart` for them. Every decision is a `session-caps` row in the decision log.

**Headless.** A `claude -p` run (`CLAUDE_CODE_ENTRYPOINT=sdk-cli`), which is what a benchmark
replay is, never has a spawn or a search denied: the row says `would-deny` and the call goes
through with a warning, so a replay measures the arm and not this guard.
`HARNESS_SESSION_CAPS_HEADLESS=enforce` turns the denial back on for a test that wants it.

Claude Code only: Codex raises no subagent lifecycle events, so a live count there could not
fall. A guard that cannot read its state lets the call through; it never denies by accident.
"""
import datetime
import importlib.util
import json
import math
import os
import re
import sys
import time
from pathlib import Path

try:
    import fcntl
except ImportError:  # pragma: no cover - a platform with no advisory locking
    fcntl = None

HOOKS = Path(__file__).resolve().parent
POINT = "session-caps"
FANOUT, SEARCH = "fan-out", "web-search"
RULE = "research-and-verification"
RULE_CAP = re.compile(r"[Ww]eb search is capped per session\W+(\d[\d,]*) calls")
WARN_SHARE = 0.8
# Same decay as the usage feed's: a start this old with no stop is not running.
RUNNING_TTL = 3 * 3600
# A spawn's start follows its PreToolUse within seconds; a reservation older than this belongs to
# a spawn that never started (refused later in the chain, or failed) and stops counting.
RESERVE_TTL = 60
HEADLESS_ENTRY = "sdk-cli"
HEADLESS_VARIABLE = "HARNESS_SESSION_CAPS_HEADLESS"
IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,79}\Z")
MAX_LINE = 4096
# The chunk a transcript past a stop is read in, so memory stays bounded however large the final
# response flushed after the stop; a longer line is skipped, and the block's reason comes after it.
CONTINUED_READ = 256 * 1024
TRANSCRIPT_TURNS = ("user", "assistant")
LOCK_WAIT = 2.0
JOURNAL_TTL = 14 * 86400


def sibling(name):
    """A module beside this hook, or None. The guard never fails over an import."""
    try:
        spec = importlib.util.spec_from_file_location(
            "harness_" + name.replace("-", "_"), str(HOOKS / (name + ".py")))
        module = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(module)
        return module
    except Exception:
        return None


# --------------------------------------------------------------------------- the caps


def fanout_cap(env, posture=None):
    """The cost variant's `max_parallel`, or None when the variant sets none or cannot be read."""
    posture = posture or sibling("posture")
    try:
        width = (posture.cost_table(env).get("switches") or {}).get("max_parallel")
    except Exception:
        return None
    return width if isinstance(width, int) and not isinstance(width, bool) and width > 0 else None


def rule_cap(text):
    """The number the research rule states as the per-session search cap, or None."""
    found = RULE_CAP.search(text or "")
    if not found:
        return None
    value = int(found.group(1).replace(",", ""))
    return value if value > 0 else None


def search_cap(env, posture=None):
    """The research rule's search cap, or None while the rule is switched off or unreadable."""
    posture = posture or sibling("posture")
    try:
        config = posture._user_config(env, False)
        if ((posture.selection(env, strict=False, config=config).get("rules") or {})
                .get(RULE) == "off"):
            return None
        roots = posture.primitive_roots(config, kind="rules")
    except Exception:
        return None
    for root in roots:
        try:
            return rule_cap((root / (RULE + ".md")).read_text(encoding="utf-8"))
        except OSError:
            continue
    return None


def past_warning(count, cap):
    return cap is not None and count > WARN_SHARE * cap


def headless(env):
    """Whether a denial is held back: a `claude -p` run nobody opted in."""
    return (env.get("CLAUDE_CODE_ENTRYPOINT") == HEADLESS_ENTRY
            and env.get(HEADLESS_VARIABLE) != "enforce")


# --------------------------------------------------------------------------- the journal


def caps_dir(env):
    base = env.get("HARNESS_HOME") or env.get("HOME") or str(Path.home())
    return Path(base) / ".local" / "state" / "agent-harness" / "caps"


def journal_path(session_id, env):
    if not isinstance(session_id, str) or not IDENTIFIER.match(session_id):
        return None
    return caps_dir(env) / (session_id + ".jsonl")


def append(path, record):
    """One line, one `os.write`, on an `O_APPEND` descriptor, so concurrent writers lose nothing."""
    data = (json.dumps(record, ensure_ascii=True) + "\n").encode("utf-8")
    if len(data) > MAX_LINE:
        return False
    try:
        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        handle = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
    except OSError:
        return False
    try:
        return os.write(handle, data) == len(data)
    except OSError:
        return False
    finally:
        os.close(handle)


def records(path):
    out = []
    try:
        with open(str(path), "rb") as handle:
            for raw in handle:
                if not raw.endswith(b"\n"):
                    break
                try:
                    record = json.loads(raw.decode("utf-8", "replace"))
                except ValueError:
                    continue
                if isinstance(record, dict):
                    out.append(record)
    except OSError:
        pass
    return out


def stamp(value):
    """A transcript record's ISO-8601 `timestamp` as epoch seconds, or None."""
    if not isinstance(value, str):
        return None
    try:
        moment = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
    except ValueError:
        return None
    if moment.tzinfo is None:
        moment = moment.replace(tzinfo=datetime.timezone.utc)
    return moment.timestamp()


def continued(stop):
    """Whether the subagent's transcript gained a turn after this journalled stop.

    Only a turn timestamped after the stop counts: the final response a stop can fire ahead of
    lands in the transcript later, but carries an earlier timestamp. An unreadable transcript
    reads as no turn, so the guard never holds a slot it cannot account for.
    """
    path, size, at = stop.get("path"), stop.get("size"), stop.get("at")
    if not isinstance(path, str) or not isinstance(at, (int, float)) or isinstance(at, bool):
        return False
    try:
        with open(path, "rb") as handle:
            end = handle.seek(0, os.SEEK_END)
            start = size if isinstance(size, int) and not isinstance(size, bool) and size >= 0 \
                else max(0, end - CONTINUED_READ)
            if end <= start:
                return False
            handle.seek(start)
            oversized = False
            while True:
                raw = handle.readline(CONTINUED_READ)
                if not raw:
                    return False
                if oversized or (len(raw) == CONTINUED_READ and not raw.endswith(b"\n")):
                    oversized = not raw.endswith(b"\n")
                    continue
                try:
                    record = json.loads(raw.decode("utf-8", "replace"))
                except ValueError:
                    continue
                if not isinstance(record, dict) or record.get("type") not in TRANSCRIPT_TURNS:
                    continue
                when = stamp(record.get("timestamp"))
                if when is not None and when > at:
                    return True
    except OSError:
        return False


def tally(entries, now=None, probe=None):
    """`{"live", "running", "reserved", "searches", "warned"}` from a session's journal.

    A start consumes the oldest reservation made before it, which is the spawn that caused it
    when spawns start in order and a harmless undercount for a moment when they do not. With
    `probe`, `continued` as a spawn passes it, a subagent whose last stop `probe` says was
    followed by a turn is running again from that stop.
    """
    now = time.time() if now is None else now
    running, reserved, searches, warned, stopped = {}, [], 0, set(), {}
    for entry in entries:
        kind, at = entry.get("t"), entry.get("at")
        at = at if isinstance(at, (int, float)) and not isinstance(at, bool) else 0
        if kind == "start" and isinstance(entry.get("id"), str):
            running[entry["id"]] = at
            stopped.pop(entry["id"], None)
            earlier = [r for r in reserved if r <= at]
            if earlier:
                reserved.remove(min(earlier))
        elif kind == "stop" and isinstance(entry.get("id"), str):
            if running.pop(entry["id"], None) is not None or entry["id"] in stopped:
                stopped[entry["id"]] = entry
        elif kind == "spawn":
            reserved.append(at)
        elif kind == "search":
            searches += 1
        elif kind == "warned" and isinstance(entry.get("cap"), str):
            warned.add(entry["cap"])
    if probe is not None:
        for agent_id, stop in stopped.items():
            at = stop.get("at")
            if isinstance(at, (int, float)) and now - at <= RUNNING_TTL and probe(stop):
                running[agent_id] = at
    live_running = sum(1 for at in running.values() if now - at <= RUNNING_TTL)
    live_reserved = sum(1 for at in reserved if now - at <= RESERVE_TTL)
    return {"live": live_running + live_reserved, "running": live_running,
            "reserved": live_reserved, "searches": searches, "warned": warned}


class Lock(object):
    """An exclusive `flock` beside the journal with a bounded wait; `held` False when it timed out.

    Check-then-append must be one step, or two spawns in one message both see room for one. A
    lock that cannot be had in time still lets the guard decide, on a count that may be one short.
    """

    def __init__(self, path):
        self.path, self.handle, self.held = path, None, False

    def __enter__(self):
        if fcntl is None:
            return self
        try:
            self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
            self.handle = os.open(str(self.path), os.O_RDWR | os.O_CREAT, 0o600)
        except OSError:
            return self
        deadline = time.time() + LOCK_WAIT
        while True:
            try:
                fcntl.flock(self.handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
                self.held = True
                return self
            except OSError:
                if time.time() >= deadline:
                    return self
                time.sleep(0.01)

    def __exit__(self, *exc):
        if self.handle is not None:
            try:
                if self.held:
                    fcntl.flock(self.handle, fcntl.LOCK_UN)
            finally:
                os.close(self.handle)
        return False


def prune(env, now=None):
    """Remove journals untouched for `JOURNAL_TTL`; done at a subagent start, at most daily."""
    now = time.time() if now is None else now
    directory = caps_dir(env)
    marker = directory / ".pruned"
    try:
        if marker.exists() and now - marker.stat().st_mtime < 86400:
            return
        for path in directory.iterdir():
            if path.suffix in (".jsonl", ".lock") and now - path.stat().st_mtime > JOURNAL_TTL:
                path.unlink()
        marker.touch()
    except OSError:
        pass


# --------------------------------------------------------------------------- the decisions


def log(answer, text, payload, env, fields):
    module = sibling("decisions")
    if module is not None:
        module.record(POINT, answer, text if isinstance(text, str) else "", payload,
                      env.get("HARNESS_RUNTIME", ""), fields=fields)


def deny(reason):
    return {"hookSpecificOutput": {"permissionDecision": "deny",
                                   "permissionDecisionReason": reason + " (session-caps)"}}


def warn(text):
    return {"hookSpecificOutput": {"additionalContext": "session-caps: " + text}}


def on_spawn(payload, env, path, workflow=False):
    """A spawn or a `Workflow` launch: deny at the fan-out cap, warn past 80% of it."""
    cap = fanout_cap(env)
    held = headless(env)
    tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
    text = tool_input.get("script") or tool_input.get("scriptPath") if workflow else tool_input.get("prompt")
    what = "Workflow launch" if workflow else "spawn"
    with Lock(path.with_suffix(".lock")):
        counts = tally(records(path), probe=continued)
        live = counts["live"]
        fields = {"cap": FANOUT, "limit": cap, "count": live, "tool": payload.get("tool_name"),
                  "headless": held}
        if cap is not None and live >= cap:
            reason = ("Fan-out cap reached: %d subagents are live in this session and the cost "
                      "variant's cap is %d. Wait for one to finish, or fold this work into one "
                      "that is running." % (live, cap))
            if workflow:
                reason += (" A workflow's agent() calls are spawns too, and none of them can be "
                           "refused once the launch is through.")
            log("would-deny" if held else "deny", text, payload, env, fields)
            if held:
                if not workflow:
                    append(path, {"t": "spawn", "at": time.time()})
                return warn("a headless run is past the fan-out cap (%d live, cap %d); this %s "
                            "was let through" % (live, cap, what))
            return deny(reason)
        if not workflow:
            append(path, {"t": "spawn", "at": time.time()})
        after = live if workflow else live + 1
        if past_warning(after, cap):
            log("warn", text, payload, env, fields)
            return warn("%d of the cost variant's %d concurrent subagents %s in use; the cap "
                        "denies further spawns once %d are live"
                        % (after, cap, "are" if after != 1 else "is", cap))
        log("allow", text, payload, env, fields)
    return {}


def on_search(payload, env, path):
    """A web search: deny past the research rule's cap, warn once past 80% of it."""
    cap = search_cap(env)
    tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
    text = tool_input.get("query")
    with Lock(path.with_suffix(".lock")):
        counts = tally(records(path))
        made = counts["searches"]
        fields = {"cap": SEARCH, "limit": cap, "count": made, "tool": payload.get("tool_name"),
                  "subagent": bool(payload.get("agent_id"))}
        if cap is not None and made >= cap:
            held = headless(env)
            fields["headless"] = held
            log("would-deny" if held else "deny", text, payload, env, fields)
            if held:
                append(path, {"t": "search", "at": time.time()})
                return warn("a headless run is past the web-search cap (%d made, cap %d); this "
                            "search was let through" % (made, cap))
            return deny("Web-search cap reached: this session, its subagents included, has made "
                        "%d searches and the research rule caps a session at %d. Continue from "
                        "what has been found, or start a fresh session for the rest." % (made, cap))
        append(path, {"t": "search", "at": time.time()})
        after = made + 1
        if past_warning(after, cap) and SEARCH not in counts["warned"]:
            append(path, {"t": "warned", "cap": SEARCH, "at": time.time()})
            log("warn", text, payload, env, fields)
            return warn("this session has made %d of the research rule's %d web searches, its "
                        "subagents included; further searches are denied at %d" % (after, cap, cap))
        log("allow", text, payload, env, fields)
    return {}


def on_subagent(payload, env, path, kind):
    agent_id = payload.get("agent_id")
    if not (isinstance(agent_id, str) and IDENTIFIER.match(agent_id)):
        return {}
    record = {"t": kind, "id": agent_id, "at": time.time()}
    if kind == "stop":
        if payload.get("stop_hook_active"):
            record["after_block"] = True
        transcript = payload.get("agent_transcript_path")
        if isinstance(transcript, str) and transcript and len(transcript) <= MAX_LINE // 2:
            transcript = os.path.expanduser(transcript)
            try:
                record["size"] = os.stat(transcript).st_size
            except OSError:
                pass
            record["path"] = transcript
    if len(json.dumps(record, ensure_ascii=True)) >= MAX_LINE:
        # `append` drops a record past MAX_LINE once escaped; the stop matters more than its path.
        record.pop("path", None)
        record.pop("size", None)
    append(path, record)
    if kind == "start":
        prune(env)
    return {}


def run(payload, env=None):
    env = os.environ if env is None else env
    path = journal_path(payload.get("session_id"), env)
    if path is None:
        return {}
    kind, tool = payload.get("hook_event_name"), payload.get("tool_name")
    if kind == "SubagentStart":
        return on_subagent(payload, env, path, "start")
    if kind == "SubagentStop":
        return on_subagent(payload, env, path, "stop")
    if kind != "PreToolUse":
        return {}
    if tool == "Agent":
        return on_spawn(payload, env, path)
    if tool == "Workflow":
        return on_spawn(payload, env, path, workflow=True)
    if tool == "WebSearch":
        return on_search(payload, env, path)
    return {}


def main():
    try:
        payload = json.load(sys.stdin)
        result = run(payload) if isinstance(payload, dict) else {}
    except Exception:
        return
    if result:
        print(json.dumps(result))


if __name__ == "__main__":
    main()
↑↓ move↵ openPagefind · indexed at build