Skip to content

v3.71.0 - Session Modes, Relay by Default, a Cheaper Status Line, Release by Pull Request

Latest

Choose a tag to compare

@lts-bob lts-bob released this 23 Oct 20:08
· 155 commits to main since this release
v3.71.0
c4bef15

Release v3.71.0 - Session Modes, Relay by Default, a Cheaper Status Line, Release by Pull Request

Release Date: 2026-10-23
Type: Minor Release

Summary

A session-modes, relay-by-default and release-pipeline release. A session now has first-class modes (awaiting-human, focus, goal-blocked, manual-goals) that are visible, logged and read by the ccy supervisor; the Rust relay is on by default for new installs and a broken relay binary is reported instead of running a call unguarded; the status line gets a cheap tick; the dispatch pool is configurable; a plan journal is one file per entry; and the daemon ships a docs-optimise skill. 27 release-note callouts are folded into Highlights below, in the authors' own words.

Read this first

  • No breaking change. CLAUDE/UPGRADES/config-changes/v3.71.0.yaml has breaking: false, so the pre-deploy gate adds no stop of its own for upgrades that cross this release. No pre-upgrade task and no post-upgrade task ships.
  • The relay is on by default for new configs. An explicit relay_enabled: false is kept; a config with no transport block adopts the relay at its next upgrade; a partial transport block is kept whole. See the relay callouts below. The manifest marks daemon.transport.relay_enabled and daemon.transport.relay_source as recommended.
  • Two options are removed, and a config that still sets them keeps working. expiry_hours of failsafe_cron_blockage_suppressor and marker_expiry_hours of idle_workable_issue_stop_gate are retired (the awaiting-human lifetime is fixed at 24 hours). A config that sets either still validates: startup logs a WARNING and config-validate lists it. Delete the key to clear it. Because no existing config stops loading, the removal is not a breaking change; the manifest lists both keys under removed with breaking: false.
  • A truth-changes entry (CLAUDE/UPGRADES/truth-changes/v3.71.0.yaml) records that paths: is the only rules-file frontmatter field Claude Code reads. If your project's docs say a rule's description decides when it loads, update them.
  • ccy supervisor changes (focus hold, restart-pending record, session modes, child-tree session ownership) start working at the next ccy relaunch or by the worker hot reload, as each callout says. The two supervisor fixes from the release review (the Meta-chord focus hold and the untyped goal-clear record) are HOST code and take effect only when ccy is relaunched; see "Fixes from the release review" below.

Highlights

Each change is described below, grouped by audience.

Everyone

The spend-meter segment reads plainly, and its unmeasured count stops counting fresh agents (Plan 00474)

The Σ⚙ status segment now labels its figure tok-eq (token equivalents: tokens weighted by price) instead of ite, which read as a word cut off mid-render, and shows thousands as whole numbers (max 278k, not max 277.5k). Its unmeasured count no longer grows by one for nearly every subagent: the context-budget gate, the resume guard and the live count now treat a transcript that holds no assistant record yet as a fresh agent whatever its size, because the hook for an agent's first tool call runs before that agent's first assistant record is flushed, behind a prompt and request records that already run to about 400 KB. An assistant record with no usage still counts as unmeasured, so a transcript format change is still reported.

The spend-meter status segment shows only the sub-agents running now (Plan 00545)

The Σ⚙ 89.7M tok-eq · 1 live (1 sonnet) · max 127k · 54 unmeasured segment is replaced by ⚙ 1 agent (sonnet) · 127k, which appears only while sub-agents are running (⚙ 2 agents (opus, sonnet) · 140k) and is absent otherwise. The cumulative tok-eq total only ever grew and had no reference point, so it is gone from the bar; hooks-daemon spend and the ledger are unchanged. A measurement-failure count is appended in red (2 unmeasured) only when it is non-zero, and shows alone when no agent is running. It counts a missing or unreadable transcript, a usage-less tail in a transcript that has usage, and a handler error; an agent that has not written its first reply yet is never counted. Run hooks-daemon status-line-explained for the text. (reported as issue #133)

The upgrade's stale-venv cleanup no longer depends on the host's python3 (Plan 00546)

The cleanup of stale venv-* directories at the end of an upgrade used to run python3 from PATH. On a host whose python3 is older than the daemon supports (3.9), that crashed on import, the failure was printed as a routine "Kept venv-* entries" line, and the upgrade reported success. The cleanup now runs on the current venv's own interpreter, falling back to the HOOKS_DAEMON_PYTHON the upgrade already resolved, and never to a bare python3. When neither is usable, or the listing step fails, the upgrade prints a WARNING: stale venv-* cleanup SKIPPED block to stderr naming the reason and leaves the stale entries in place (bin/hooks-daemon list-venvs shows them). The warning does not fail the upgrade, since the new daemon is already verified running by then. (reported as issue #130)

The sub-agent full-QA gate names a script it could not finish, and reads scripts faster (Plan 00474)

subagent_full_qa_blocker reads a Python script that a sub-agent runs, to check whether it starts a full QA run. A script that builds a lot of argument lists step by step (a.append(...), a = a + [...], a.insert(...)) and hands each list to subprocess could use up the parse budget for one judgement. When that happened, the remaining calls were skipped without any report, so a full run built later in the script was allowed silently. The handler now stops at the first call it cannot afford and judges the script as too-much-code-to-judge, giving the same reason as every other budget overrun. As with any other command it could not fully read, your unseen_policy setting decides the outcome (advisory by default, or deny). A full run the handler does see positively still wins and is denied as before. Scripts over the size that is parsed are unchanged: they are scanned for a declared program's name, as before. Each parsed script is now walked once per judgement instead of once per pass, which roughly halves the time to judge a long script. No config keys change and no action is needed after upgrading.

A Python below the minimum gets one clear line, not a traceback (Plan 00546)

The minimum Python (requires-python, currently 3.11) is now declared once, in src/claude_code_hooks_daemon/python_floor.py, and checked before anything else at every entry point an arbitrary interpreter can start: daemon/paths.py, daemon/signal_standalone.py and install.py run as scripts, any python -m claude_code_hooks_daemon..., and bin/hooks-daemon signal (which runs the guard under the system python3 before executing the signal script). An older interpreter now prints one line naming the version it found and the version required, and exits non-zero. Before this, paths.py raised AttributeError/TypeError on 3.8/3.9, install.py a ModuleNotFoundError for tomllib, and signal_standalone.py accepted 3.8 and then crashed on dataclass(slots=True). The bin/hooks-daemon signal wrapper previously accepted any python3 from 3.8 and now requires the declared minimum.

Install, upgrade and support scripts run the resolved interpreter, or fail with a named reason (Plan 00546)

The shipped install, upgrade and support scripts no longer fall back to a bare python3 from PATH, which could be older than the declared minimum. scripts/install/venv.sh (ensure_venv, ensure_venv_locked), scripts/install/python_fingerprint.sh, install_version.sh, upgrade_version.sh, setup_worktree.sh, health_check.sh, detect_location.sh, scripts/install/prerequisites.sh, scripts/venv-include.bash, the v2 upgrade scripts under CLAUDE/UPGRADES/v2/ and the acceptance-test invoke.sh now run the interpreter the project resolved (the daemon venv's bin/python, $HOOKS_DAEMON_PYTHON, or one found by find_latest_python against the declared floor). When none can be found they stop with a message naming what was missing instead of silently carrying on or crashing on old syntax. A new QA check, scripts/qa/check_bare_python_invocation.py, keeps shell scripts from reintroducing the pattern; the remaining bare python3 calls in init.sh and install.sh are version-checked bootstrap or stdlib-only forwarder snippets proven to run on Python 3.8 and 3.9.

detect_install_mode (scripts/install/project_detection.sh) now fails loudly with exit code 1 and a stderr diagnostic when it cannot read the config (no resolved interpreter, or no PyYAML), where it used to answer normal silently. Its caller in detect_and_validate_project stops with a named error. The v2.11-to-v2.12 verification.sh also no longer aborts after its first check under set -e.

Client projects

docs-optimise skill, instruction-loading contract and docs-qa --footprint (Plan 00532)

The daemon now ships a pinned contract for how Claude Code loads CLAUDE.md, rules and @-imports, and builds three things on it. docs-qa --footprint reports what your project loads at session start versus on demand (line and byte totals, the Claude Code version the contract was audited against and the installed one, and what the contract leaves unmodelled), and never proposes changes to the daemon-owned block, daemon-deployed rules, agents and skills, CLAUDE.local.md or user-scope files; when walking nested CLAUDE.md files it skips your declared layout.vendor_dirs and daemon.exclude_paths (rules and imports are counted wherever they sit, because Claude Code loads them regardless), and it is run through .claude/skills/docs-optimise/scripts/footprint.sh. The new docs-optimise skill deploys to .claude/skills/ and turns that measurement into a restructure proposal under untracked/scratch/docs-optimise/; it proposes and never applies, so no tracked file is modified. The rules_file_shape and orphan-shrink checks now scan nested .claude/rules/ folders, and the R7a wording names paths as the only rules-file frontmatter field. In the daemon repository only, a newer installed Claude Code than the audited one raises a re-audit advisory. No config keys are added and no action is needed after upgrading. (reported as issue #123)

Ansible lint can run under a declared governing ansible.cfg (Plan 00547)

A play outside its ansible.cfg's tree resolved no module root, so lint_on_edit ran ansible-playbook --syntax-check from the workspace root and staged_lint_gate ran it from the daemon's own working directory, neither with the config. A valid play that read CONFIG_FILE was then denied for a context error that was not its own defect. New layout.ansible_configs maps a play glob to a repo-relative ansible.cfg (for example {"plays/**": "infra/ansible.cfg"}); a matching play is checked from the config's directory with ANSIBLE_CONFIG set, by both gates, and a declared file that does not exist is a loud error. With no marker and no declaration both gates now run from the same workspace root, and a failed Ansible check that ran with no config still denies but adds a line saying no ansible.cfg was in effect and naming the option. Other languages are unchanged. (reported as issue #128)

Operators

A saturated daemon can be restarted from inside a session (Plan 00533)

When many concurrent sessions filled the daemon's dispatch pool, every PreToolUse call was denied as "dispatch pool saturated", including the exact hooks-daemon restart command that would have cleared it; that command is now judged without needing a slot of the shared pool (and the hook client lets it through a saturation deny from an older daemon), and a stop under load no longer escalates to SIGKILL, because the SIGTERM grace (8s) now outlasts the daemon's 5s drain of in-flight requests. The pool size is configurable as daemon.chain.max_inflight (default 16, unchanged), a full pool now waits daemon.chain.slot_wait_seconds (default 2s) for a slot before refusing, and saturation lasting 10s or more is logged as a WARNING naming its duration, repeated at most every 30s. Stop and SubagentStop still fail closed under saturation. (reported as issue #124)

The ccy supervisor no longer types into a focused sub-agent (Plan 00537)

The ccy supervisor used to type its continue, notices, /compact, model switch, abandoned-box Enter and restart /exit into whichever thread had focus. It now holds every one of those sends after you press a bare Up or Down arrow, because that key may have moved focus into the sub-agent panel or opened a sub-agent's transcript. Nothing is typed until you press Esc. While a send is held, the status line shows "supervisor holding: focus may be on a sub-agent; press Esc to return to the main thread", and decision.log records which send is waiting. The banner grows louder after the first escalation bound, which is set with the new CCY_FOCUS_HOLD_ESCALATE_SECONDS option (default 300,900). The hold has no off switch, and it starts working at the next ccy relaunch. (reported as issue #125)

SessionEnd hooks can tell a supervisor restart from a real exit (Plan 00539)

When a supervisor plugin asks for a restart, the ccy supervisor now writes a private restart-pending.json record before it types /exit, and removes it once the child has exited or the request is abandoned. The child gets the record's path in CCY_SUPERVISOR_RESTART_PENDING. A SessionEnd hook can now skip announcing a departure that is really a restart. It treats its session as restarting only when the file exists, names the hook's own session id, and its deadline has not passed. The full reader contract is in CLAUDE/development/CcySupervisor.md, "Exit for restart". A restart held because focus may be on a sub-agent writes no record, and the change starts working at the next ccy relaunch. (reported as issue #127)

An interpreter-prefixed ccy wrapper broken by 3.69.0 and 3.70.0 is repaired (Plan 00540)

3.69.0 and 3.70.0 broke a ccy wrapper written with a Python interpreter in front of the supervisor (python3 .claude/ccy/claude-supervise.py): the upgrade pointed it at the claude-supervise launcher, which is a sh script, so Python failed on it with a SyntaxError and every ccy launch died. The next upgrade now rewrites the interpreter word to sh (sh .claude/ccy/claude-supervise), both for a wrapper not yet migrated and for one already broken that way, keeping a quoted path's quotes. When a dropped word pinned a specific Python, the upgrade output names every such pin with an export CCY_PYTHON=... line to keep it; that note appears only on the upgrade that rewrites the line, so read the upgrade output. A wrapper with Python options before the path (python3 -u ...) or a variable as the command word ($PY ...) is left unchanged and reported, because the sh launcher cannot pass Python options on: change it by hand to sh .claude/ccy/claude-supervise .... ccy_supervisor_integrity now also warns at session start about a Python interpreter, or a variable that may hold one, in front of the launcher. (reported as issue #126)

The removed transcript_archiver advisory now names the leftover directory (Plan 00541)

If your config still carries a transcript_archiver key, the advisory the daemon prints for it now says what to clean up as well as what to delete from the config: delete untracked/transcripts/ to reclaim the disk. Those archives are copies of transcripts you still have, so nothing is lost by removing them. (reported as issue #52)

A usage pause lifts before its resume time when a fresh reading is under the ceiling (Plan 00479)

A session paused on one account's usage window used to hold until that window's reset even after it moved to an account with plenty of headroom, because every resume tick before the recorded resume time was dropped unread. The resume cron's early tick (it fires every ten minutes) now reads the daemon's usage snapshot, which costs no model turn: a reading seen within the last 15 minutes that is under the ceiling lifts the pause, one that is still over on a different window silently moves the recorded resume time, and a stale or missing reading drops the tick as before. It trusts the same host-wide snapshot a held prompt already does. Behaviour at or after the resume time, the owner's usage-pause clear override and the tool and stop gates are unchanged. (reported as issue #132)

The status line has a cheap tick when a relay or nc client is configured (Plan 00542)

Every status-line tick used to source the 3,800-line .claude/init.sh and start a fresh python3 before any byte reached the daemon (about 0.08 CPU-seconds per tick). The generated .claude/hooks/status-line now carries a fast path, built from bash builtins only, that runs first: it asks the daemon's per-event status socket for raw text and prints the reply (about 0.005 CPU-seconds, measured with the relay binary). It uses only the client your daemon.transport config selects: the relay binary when relay_enabled is true, else nc when nc_enabled is true. With both off, the forwarder is unchanged and nothing is faster. There are no silent fallbacks: when the relay is enabled but its binary is missing, when nc is enabled but the install probe says it cannot do unix sockets, on a timeout, on any other client failure, on an empty reply, or on a reply from an older daemon, the status line shows one red line naming the fault and the fix (for example hooks-relay missing or not executable: run bin/hooks-daemon transport on) instead of quietly taking the slow path. The one hand-off to the existing path (stdin intact) is a daemon that is not running (no socket file, or a refused connection): the start-up path starts it and reports as before (DAEMON FAILED, the loading text). The daemon marks its raw reply with a leading record-separator byte (0x1e) and the fast path prints only a reply that carries it, so a daemon that predates this change, which answers with JSON, is reported as daemon older than its forwarder: run bin/hooks-daemon restart instead of having its JSON printed. The daemon renders its own error and no-data lines (⚠️ ERROR: <msg>, ⚠️ NO STATUS DATA) exactly as the python rung does. The legacy path's version handling runs only on the hand-off. Regenerate forwarders with the normal upgrade or transport relay on|off. (reported as issue #131)

A plan journal is one file per entry, and existing journal files are closed (Plan 00543)

mkplan.bash --journal <plan> <category> <body-file> now writes each entry as its own file, JOURNAL/YYYY-MM-DD/HHMMSSZ-<category>-<hex>.md, instead of appending to a per-day file. Two sessions journalling the same plan on the same day no longer touch the same file, so merge conflicts in journals are gone. Existing NNNNN-Journal-YY-MM-DD.md day-files stay valid and are read as before, but are read-only.

Hand edits of any journal file are denied: plan_journal_guard refuses to create an entry file by hand or change an existing one (only removing conflict markers, or restoring the committed bytes, passes). The journal-dayfile-is-today check now closes existing journal files at edit time and has a commit half that blocks a staged modification or deletion of an entry file, or a rename that changed its content; a pure rename (archiving a plan) is clean. The other journal checks, the plan templates and the docs read entry files and legacy day-files alike. Reported as issue #135.

issue-validity N --claim works when no approved author list is configured (Plan 00544)

bin/hooks-daemon issue-validity used to exit 4 for every mode when approved_issue_authors was unset, while the github_issue_assignment_guard deny text told you to run issue-validity N --claim in exactly that configuration, so the one fix the guard named could not be run. The refusal now applies only to --list-eligible (autonomous picking with no list would make every author eligible; it still exits 4 and makes no gh call). A single-issue check or --claim acts on an issue you already chose, so it proceeds and prints [n/a] author: no approved issue authors configured. A configured list still rejects an unapproved author. No config change is needed. (reported as issue #129)

A missing relay binary is reported, and transport on says when it built one (Plan 00529)

When daemon.transport.relay_enabled is true but the relay binary (untracked/bin/hooks-relay, or your relay_binary override) is missing or not executable, relay-eligible hooks run on the fallback transport, and until now only the status line said so. Three places now say it plainly. bin/hooks-daemon transport on, when it has to build or download the binary, prints transport on: relay binary provisioned via <route> at <path>, verifies the relay, and no longer prints "relay already enabled — nothing to do" after doing that work. bin/hooks-daemon status prints a WARNING: line naming the binary path and the fix bin/hooks-daemon transport on, whether or not the daemon is running; its exit code is unchanged and still means only running or not running (use health for a non-zero exit on a degraded state). A new SessionStart advisory, relay_binary_checker (on by default, priority 72), prints the same warning at the start of each session. Both are silent when the relay is disabled or the binary is executable. (reported as issue #131)

The Rust relay is on by default for new installs, and install and upgrade provision it (Plan 00529)

A new config, whether from init-config or copied from .claude/hooks-daemon.yaml.example, now enables the relay: daemon.transport.relay_enabled: true with relay_source: auto. auto builds the relay with rustc and the musl target when both are present, and otherwise downloads the digest-verified release asset. Install and upgrade now put the binary in place through one shared script (scripts/install/relay_provision.sh) instead of three copies of inline code. When neither route works the output prints a loud banner naming the binary path and the fix (bin/hooks-daemon transport on, or how to install rustc and the musl target), and repeats the banner just before the final exit so it is the last thing you read. The install or upgrade still completes: the bash transport enforces every rule, the relay is only the fast path. The SessionStart relay_binary_checker and the status WARNING report the same condition afterwards.

Existing configs: an explicit relay_enabled: false is kept. A config with no transport block adopts the relay at its next upgrade through the example merge (a coordinator ruling the owner can overturn). A partial transport block, such as one naming only nc_enabled, is kept whole, so the relay stays off until you add relay_enabled: true. To opt out, set daemon.transport.relay_enabled: false. The model default in code is unchanged. (reported as issue #131)

A broken relay binary never runs a tool call unguarded, and every answer says so (Plan 00529)

With the relay enabled, a relay binary that was executable but corrupt (truncated, wrong architecture, garbage bytes) made the forwarder's exec fail under set -e, so the hook exited non-zero with nothing on stdout and Claude Code ran the tool with no PreToolUse guard at all. A missing binary fell through to the bash path with no record. The regenerated forwarders now run the relay only when the binary is an executable regular file starting with an ELF header or #!, and survive a failed exec. In every case where the event's socket exists but the relay did not take the call, the call goes to the bash path, where every handler judges it, and the daemon appends one line to every relay-eligible answer: WARNING: relay is enabled but its binary is missing or cannot run: <path> ... Fix: bin/hooks-daemon transport on. The line is text only, in additionalContext or systemMessage; no verdict changes. At session start, relay_binary_checker starts the transport on provisioning in the background, once per cause (state under untracked/relay-repair/), and reports on a second line that the repair started, is running, or FAILED with its log; a failed repair is not retried automatically. Run bin/hooks-daemon transport on (or upgrade) to regenerate the forwarders. A worktree's forwarders are unchanged and still decline the relay without a warning.

"Awaiting human" is now a session mode, ended only by a human prompt in the same session (Plan 00501)

A stop that declares STOPPING BECAUSE: [awaiting-human] ... (or one of the four legacy phrasings) now puts the session into the awaiting-human session mode in the daemon's session-modes registry, for 24 hours, instead of writing human-input-blockage-marker.json. failsafe_cron_blockage_suppressor and the stop-idle gate both read the registry, so the failsafe tick and declared persistent_crons ticks are dropped while the session is in a blocked mode (awaiting-human or blocked on a goal). The mode ends only when a human types a genuine prompt in the SAME session; a cron tick, a supervisor line or a prompt in another session leaves it in place. The legacy marker file is no longer read. The expiry_hours option of failsafe_cron_blockage_suppressor and the marker_expiry_hours option of idle_workable_issue_stop_gate are retired: the 24-hour lifetime is fixed. A config that still sets either is valid, but startup validation logs a WARNING and bin/hooks-daemon config-validate lists it as a warning, naming the full dotted path; remove the key to clear it.

Focus mode points one session at one task (Plan 00501)

You can now type hooks-daemon focus <task> at the start of a prompt to put that session, and only that session, into focus mode. Any case works, and so do the speech-to-text spellings hooks daemon and hooks demon. Type hooks-daemon focus off to end it; done, end and disable also work. The new focus_mode_trigger handler is on by default and states each change to the agent in one line.

While a session is focused:

  • Every work-driving (drives_autonomy) handler is skipped for it, so goal_injection writes no .goal-intent.
  • Declared persistent_crons ticks are dropped, even for a job that sets runs_while_awaiting_human.
  • The watchdog tick is delivered.
  • The failsafe tick is delivered with the line "focus mode: the active task is ; other plans are out of scope".
  • A stop without a reason gets a one-line focus reminder in place of the goal-ledger plan list.

Entering focus clears an existing goal once. The ccy supervisor types /goal clear at its next idle; without the supervisor, a human must type it. Focus lasts 12 hours, and each prompt the human types renews it. The agent is told once when it expires.

The same operations are available as /mode focus <task>, /mode focus off, hooks-daemon session-mode get|focus <task>|focus off, and the get_session_modes, enter_focus and exit_focus IPC actions. The daemon-wide /mode, get-mode/set-mode and get_mode/set_mode are unchanged. R-DECLARED-CRON-SUPPRESSED now names focus as well as the blocked modes.

Session modes are visible on the status line and logged (Plan 00501)

A session's modes are now visible and explained:

  • Status line. The new session_mode_indicator segment (on by default, priority 18) shows ⚑ <top mode> +N: the top mode by precedence (usage-paused > goal-blocked > awaiting-human > focus > unattended) and how many more are active, with the focus task when focus leads. It shows nothing when the session has no mode. The daemon-wide unattended mode is shown there too. A red ⚑ modes unreadable or ⚑ usage-pause record unreadable means the mode state could not be read; see the daemon log. hooks-daemon status-line-explained describes it.
  • After a daemon restart, the next prompt in each session gets one SESSION MODE: line, whatever the prompt is. The line names the modes that survived the restart and the modes the restart cleared. It also says when the daemon is in unattended mode. Expiries are stated in the same single line. A usage-paused session sees no prompt until its resume cron fires, so a usage pause recorded before the restart is stated on the resume tick that finds usage still over the ceiling, next to the instruction to stay paused.
  • A focus change or renewal that cannot be recorded is stated to the agent (FOCUS MODE NOT CHANGED or FOCUS MODE NOT RENEWED, with the error) as well as logged, and the pending SESSION MODE: line is still delivered on the same prompt.
  • Transition log. Every mode entered, exited, expired or cleared by a restart is recorded with what triggered it in session-mode-transitions.jsonl under the daemon's untracked directory. The log keeps the newest 500 records. hooks-daemon mode-history [--session ID] [--limit N] [--json] prints every session's live modes and the recent transitions; an unreadable log is reported as an error, never as an empty history. A log whose content does not parse is moved aside under a unique session-mode-transitions.jsonl.<time>.<token>.corrupt name and a fresh log is started. A log that cannot be read (for example, permission denied) is left where it is, and the failure is logged.

The ccy supervisor types no work-driving prompt while a session is blocked (Plan 00501)

The ccy supervisor now reads the daemon's per-session <session>.session-modes file. While its session is blocked (awaiting-human or goal-blocked) it does not type the post-compact continue, a /goal, the standing-authorisation reminder or the session-actions directive; each skip is a named noop: line in decision.log (for example noop: post-compact continue skipped: session BLOCKED (awaiting-human)). /compact itself still runs. A pending goal clear now wins over a pending goal while blocked, and the five-per-process goal-clear cap does not refuse it; a higher ceiling of fifteen per process still applies while blocked, so a clear trigger that cannot be deleted is not retyped on every idle tick. A session-modes file the supervisor cannot read is not treated as "no modes": the same prompts are withheld, decision.log names the file and the error, and the status line shows a warning until the file is readable again. The change is in the supervisor's worker, so it reaches a running session by the worker hot reload; no ccy relaunch is needed.

The goal-loop detector observes by default; mode: act makes it end a loop (Plan 00501)

The new Stop handler goal_loop_detector watches for a session that keeps stopping with STOPPING BECAUSE: while the goal evaluator keeps answering "not met", with no human prompt in between. By default (mode: observe) it only logs near-miss, would-enter and loop-reset lines to the session-mode transition log; it never blocks and never enters a mode. Setting mode: act under handlers.stop.goal_loop_detector.options opts in: at the threshold it enters the goal-blocked session mode and clears the goal, so the supervisor stops driving the loop. A goal-loop block ends on a human prompt, a repository change, the blocked_hours timer, or hooks-daemon session-mode unblock; each exit is logged with its cause. session-mode unblock ends only a goal-loop block and refuses a block the session declared itself with [awaiting-human], which only a genuine human prompt ends. Without ccy the only backstop is Claude Code's built-in cap of 8 consecutive stop-hook continuations (reset by each tool call, raised by CLAUDE_CODE_STOP_HOOK_BLOCK_CAP), after which it pauses the goal until the next message; a loop that makes tool calls between stops never reaches the cap. Options and defaults are in docs/guides/HANDLER_REFERENCE.md.

Every typed /goal clear is attributed to the ccy supervisor or the human (Plan 00501)

Claude Code records a typed /goal clear (or stop, off, reset, none, cancel) identically whoever typed it. The ccy supervisor now writes a <session>.goal-clear-injected record in the context-sidecar directory immediately before it types a clear. If that record cannot be written, the supervisor does not type the clear and logs noop: goal clear NOT typed: attribution record could not be written to its decision.log. On the next Stop, goal_loop_detector finds each new clear in the transcript and logs a goal-clear line to the session-mode transition log. That line's detail is supervisor when a fresh matching record exists (the record is consumed). A clear stamped before the record was written is never the supervisor's, so a human clear typed just ahead of a pending supervisor clear stays human (Plan 00474 N534). Otherwise it is human, or supervisor-assumed when the record could not be read. In the supervisor-assumed case the agent is also told at its next prompt to ask the human to type /goal clear again if they typed it. A clear is attributed once, including after a daemon restart. Three limits apply. A clear typed while Claude Code is mid-turn is not attributed. The clearing aliases come from the shipped binary and were not observed in a transcript. With goal_loop_detector disabled, nothing attributes clears. The supervisor change takes effect when its worker hot-reloads.

manual-goals session mode hands a session's goal to the human (Plan 00501)

A new session mode, manual-goals, stops the daemon making automated goal changes for one session. It is entered when a human clears a goal (a /goal clear that goal_loop_detector attributes to the human), by hooks-daemon goals off (or manual) typed as the whole prompt or run from the CLI, or by /mode goals manual. While it holds, no .goal-intent is written for the session, so the ccy supervisor types no /goal, and a plan retiring no longer clears the goal. A /goal <condition> the human types still works and leaves the mode in place. The goal-loop detector's clear, the clear on entering focus, hooks-daemon clear-goal, continue, cron ticks and every guard are unaffected. The mode ends only at hooks-daemon goals on (or auto, or /mode goals auto) or with the session: every prompt renews its expiry, so a live session never times out of it, and it survives a daemon restart. The status line shows a magenta ⚑ manual-goals, ranked below awaiting-human and above focus. The IPC actions are enter_manual_goals and exit_manual_goals. Full reference: CLAUDE/SessionModes.md.

The ccy supervisor is safe outside a private PID namespace (Plan 00551)

The ccy supervisor now counts a session as its own only when the session id is in the environment of the Claude process it started, or of one of that process's descendants. Before this, it took every session id it could see in /proc, which is safe only inside a private container PID namespace. On a host, or with a shared PID namespace, it could act on another terminal's compaction signals and sidecars (reported as issue #138). The change reaches a running session by the worker hot reload; until the next ccy relaunch, the reloaded worker takes its own sessions from every process the older host started (the host's descendants, excluding the worker itself), and acts on nothing if the host is gone.

Fixes from the release review

The release's code review found these defects in the changes above; each is fixed in this release (Plan 00474).

  • docs-qa --footprint follows Claude Code 2.1.309. Since 2.1.309, a file that loads on demand (a nested CLAUDE.md and the files it imports) and is over Claude Code's per-file limit is loaded only up to the limit, with a note to read the rest; it is no longer skipped. The footprint now marks such a file with a new truncated flag (also in its JSON output) and says "Claude Code loads only its start", using the smallest documented limit, 40,000 characters. Only a launch file over 4 MiB is reported as skipped. A nested CLAUDE.md's imports count as on-demand, and a backticked or fenced <hooksdaemon> mention is no longer taken for the daemon-owned block. The instruction-loading contract the footprint is built on is re-audited against Claude Code 2.1.309.
  • ccy supervisor: HOST code, takes effect only when ccy is relaunched. The worker hot reload does not carry these two fixes. Esc followed by a printable key (an Alt/Meta chord such as Alt+B) no longer releases the focus hold; a bare Esc still does. A /goal clear the supervisor decided on but did not type (the send was held, or the terminal write failed) now removes its attribution record, so a human clear soon afterwards is not credited to the supervisor. Separately, a failed goal-clear record write no longer leaves a temp file behind.
  • Session modes. One unreadable *.session-modes file is logged and skipped instead of breaking the registry for every session. A /goal clear is attributed once, even after a restart has evicted its transition-log record (a bounded goal-clear-attributed.json under the daemon's untracked directory remembers it). The manual-goals message now says the goal-loop detector can still clear a goal to break a loop. The session_mode_indicator segment shows a named error instead of failing when the usage-pause path cannot be resolved.
  • Dispatch and goal loop. A timed-out capacity-exempt call (such as the restart command) no longer counts against the ordinary straggler cap, and the goal-loop detector never checkpoints a transcript mid-line.
  • Relay repair. The session-start relay repair records its child's pid at once and reaps the child, so a repair that died reads as FAILED instead of running for ever. The install and upgrade banner names the relay binary's actual defect instead of always saying "missing or not executable".
  • Lint gates in a sub-project. lint_on_edit and the commit-time staged_lint_gate now run a sub-project file's linter from the same directory and launch the same binary, searching the sub-project's own tool directories (such as node_modules/.bin) before PATH.

Changes

Added

  • Session modes: awaiting-human is a mode ended only by a human prompt in the same session (no marker file); hooks-daemon focus <task> (handler focus_mode_trigger, /mode focus, hooks-daemon session-mode) points one session at one task; manual-goals hands a session's goal to the human (hooks-daemon goals off|on, /mode goals manual|auto); the session_mode_indicator status segment shows the top mode; hooks-daemon mode-history prints the transition log (Plan 00501).
  • goal_loop_detector (Stop; observes by default, mode: act ends a loop) and attribution of every typed /goal clear to the supervisor or the human (Plan 00501).
  • The ccy supervisor reads session modes and types no work-driving prompt while a session is blocked (Plan 00501); holds every send while focus may be on a sub-agent (Plan 00537); writes a restart-pending.json record before a restart /exit (Plan 00539); and counts as its own only sessions in its child process tree (Plan 00551; Addresses #138).
  • daemon.chain.max_inflight and daemon.chain.slot_wait_seconds configure the dispatch pool; the exact restart command is judged without a pool slot, and sustained saturation is logged (Plan 00533; Addresses #124).
  • daemon.transport.relay_source: auto, one shared scripts/install/relay_provision.sh for install and upgrade, and the SessionStart relay_binary_checker; hooks-daemon status warns about a missing relay binary (Plan 00529; Addresses #131).
  • A status-line fast path built from bash builtins that asks the daemon for raw text (Plan 00542; Addresses #131).
  • The docs-optimise skill, the pinned instruction-loading contract and docs-qa --footprint (Plan 00532; Addresses #123).
  • layout.ansible_configs maps a play glob to the ansible.cfg both lint gates run under (Plan 00547; Addresses #128).
  • src/claude_code_hooks_daemon/python_floor.py declares the minimum Python once and every entry point refuses an older one in a single line; scripts/qa/check_bare_python_invocation.py keeps shell scripts from calling a bare python3 (Plan 00546; Addresses #130).
  • mkplan.bash --journal writes one file per entry under JOURNAL/YYYY-MM-DD/ (Plan 00543; Addresses #135).
  • The release pipeline: a release-gate.yml workflow runs the whole gate on a release/<version> pull request, the release state records that run, scripts/release/check_release_ci.py verifies it before tagging, and the release_merge_freeze project handler freezes other merges to main meanwhile (Plan 00476; Addresses #107).
  • An early usage-resume tick re-evaluates the pause against a fresh reading (Plan 00479; Addresses #132).

Changed

  • The example config and init-config enable the relay (relay_enabled: true, relay_source: auto); an explicit false and a partial transport block are kept (Plan 00529).
  • The spend-meter segment is ⚙ 1 agent (sonnet) · 127k, shown only while sub-agents run, with a plain unit and fresh agents no longer counted as unmeasured (Plans 00545, 00474; Addresses #133).
  • The upgrade's stale-venv cleanup runs on the resolved interpreter and warns loudly when it cannot (Plan 00546).
  • Install, upgrade and support scripts run the resolved interpreter or stop with a named reason (Plan 00546).
  • subagent_full_qa_blocker judges a script it cannot finish as too-much-code-to-judge, and parses each script once per judgement (Plan 00474).
  • The transcript_archiver advisory names the leftover untracked/transcripts/ directory (Plan 00541; Addresses #52).
  • A usage-pause lifts before its resume time when a fresh reading is under the ceiling (Plan 00479).
  • The CPU-timing sweeps run in their own CI job (Plan 00476), and a lint timeout is scored as an inconclusive probe (Plan 00474).
  • docs-qa --footprint marks an on-demand file over Claude Code's per-file limit as truncated (it loads only its start); the instruction-loading contract is re-audited against Claude Code 2.1.309 (Plan 00474 N535).
  • The manual-goals message no longer promises that no automated clear happens (Plan 00474).

Removed

  • The expiry_hours option of failsafe_cron_blockage_suppressor and the marker_expiry_hours option of idle_workable_issue_stop_gate (the awaiting-human lifetime is fixed at 24 hours). A config that still sets either validates and logs a WARNING; not breaking (Plan 00501).

Fixed

  • A relay binary that is corrupt no longer lets a tool call run with no PreToolUse guard, and every relay-eligible answer names the fault (Plan 00529).
  • issue-validity N --claim works when no approved author list is configured (Plan 00544; Addresses #129).
  • A ccy wrapper written with an interpreter in front of the supervisor, broken by 3.69.0 and 3.70.0, is repaired by the next upgrade (Plan 00540; Addresses #126).
  • The doc-write jail runs under Python 3.8 and 3.9 (Plan 00546; Addresses #130).
  • A saturated daemon no longer escalates a stop to SIGKILL (Plan 00533).
  • A status-line fault is reported in red instead of silently taking the slow path (Plan 00542).
  • The ccy supervisor no longer acts on another terminal's sessions outside a private PID namespace (Plan 00551; Addresses #138).
  • Plan QA and docs QA read journal entry files and legacy day-files alike, and no handler message tells an agent to write to a day-file (Plan 00543).
  • The release-review fixes listed in "Fixes from the release review" above (Plan 00474).

Upgrade Instructions

This release is not breaking (breaking: false, no pre-upgrade task, no post-upgrade task). The two removed options still validate and log a warning. See CLAUDE/UPGRADES/v3/v3.70.0-to-v3.71.0/ for the callouts behind the Highlights and CLAUDE/UPGRADES/config-changes/v3.71.0.yaml for the added and changed keys.

Installation

New Installations

mkdir -p .claude
git clone -b v3.71.0 https://github.com/Edmonds-Commerce-Limited/claude-code-hooks-daemon.git .claude/hooks-daemon

# The installer builds the fingerprint-keyed venv, installs the package and
# starts the daemon. Never hand-build a venv — the resolver refuses the
# retired pre-v3.7.0 layout.
bash .claude/hooks-daemon/scripts/install_version.sh "$PWD" "$PWD/.claude/hooks-daemon"

Upgrading Existing Installations

cp .claude/hooks-daemon.yaml .claude/hooks-daemon.yaml.backup
git -C .claude/hooks-daemon fetch --tags

# Rebuilds the venv and reinstalls the package for the target version. Runs the
# target's own upgrade.sh: the installed one may predate the pre-deploy gate.
tmp="$(mktemp)"
git -C .claude/hooks-daemon show "v3.71.0:scripts/upgrade.sh" > "$tmp"
bash "$tmp" --project-root "$PWD" "v3.71.0"
# Exit 3: read what it lists, then re-run with the --skip-reading-confirmation=<digest> it printed.

.claude/hooks-daemon/bin/hooks-daemon restart

Testing

  • Tests: the full QA gate runs in CI on the release pull request (workflow release-gate.yml, check Release gate); the result is recorded in the release state and verified before the tag.
  • Version bump completeness: tests/unit/qa/test_version_bump_completeness.py passes (7 of 7).
  • Type Safety: MyPy strict mode compliant
  • Security: Bandit scan clean

Full Changelog

Compare: v3.70.0...v3.71.0