pre-agent-cap.mjs (S78) enforces the Phase 5 loop bound only while a scope
marker exists for the calling session. Nothing wrote that marker, so the hook
shipped correct but latent. Phase 5 now writes it at loop start and removes it
on all three exits.
- Write: ${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json with
{runId, startedAt}, keyed by CLAUDE_CODE_SESSION_ID. Verified 2026-08-12 that
this equals the session_id on the hook's PreToolUse payload.
- runId must be the same --run-id the ledger is counted under; a mismatched id
counts zero turns and enforces nothing.
- Fail-soft on write: the hook is defence in depth, research-loop-cap.mjs stays
the gate. Report and continue. The reverse (skipping the budget gate because
a marker exists) stays forbidden.
- Removal on every exit, load-bearing on the exhausted one: the hook keeps
denying WebSearch/WebFetch/Task while the marker is there, and Phase 6 spawns
agents. Crash is covered by the hook TTL, not by cleanup - stated as such
rather than claiming cleanup covers it.
- Marker written in Phase 5, not Phase 4.5: 4.5 mines already-retrieved Phase-4
results and spends no loop turns, so scoping there widens the window for
nothing. Pinned by a test.
Six pins in tests/lib/doc-consistency.test.mjs derive the directory name from
SCOPE_DIRNAME in the hook and the payload fields from marker.runId/startedAt,
so drift in either direction fails. hooks/scripts/pre-agent-cap.mjs untouched.
Verified end-to-end with the snippets as shipped: marker written -> hook allows
under budget, denies 8/8 at budget, allows again after removal; removal is
idempotent; unset CLAUDE_PLUGIN_DATA takes the fail-soft branch.
Suite 937 (935/0/2, baseline 931 + 6).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R77nGJjZ1hqjAQQHefFdnc
160 lines
7.6 KiB
Markdown
160 lines
7.6 KiB
Markdown
# Spike: does a plugin `PreToolUse` hook reach sub-agent tool calls?
|
||
|
||
**Date:** 2026-08-09
|
||
**Claude Code version:** 2.1.226
|
||
**Plugin:** voyage 5.9.1 (installed from `ktg-plugin-marketplace`)
|
||
**Gates:** Step 10 of `plan.md` (`2026-06-30-trekresearch-storm-upgrade`)
|
||
|
||
## Question
|
||
|
||
Step 10 wants to enforce the conversation-turn cap in a `PreToolUse` hook. That
|
||
is only viable if a **plugin** `PreToolUse` hook fires on tool calls made
|
||
*inside a sub-agent*. If it does not, the cap must be enforced somewhere else
|
||
and Step 10 becomes a documented downgrade instead.
|
||
|
||
The question is genuinely open, not answerable from docs alone: the current
|
||
[hooks reference](https://code.claude.com/docs/en/hooks) states that sub-agent
|
||
tool calls fire the same hooks and carry `agent_id` / `agent_type`, while
|
||
[issue #34692](https://github.com/anthropics/claude-code/issues/34692) reported
|
||
the exact opposite behaviour. The answer is therefore version-dependent and had
|
||
to be measured on the version actually in use.
|
||
|
||
## Method
|
||
|
||
A one-shot probe hook was registered for the `WebSearch` matcher, and a
|
||
**headless child session** was launched to exercise it. The child was used
|
||
because hooks are resolved when a session starts — a matcher added mid-session
|
||
cannot be observed by the session that added it.
|
||
|
||
Two deviations from the step as originally written, both forced and both
|
||
verified not to affect the result:
|
||
|
||
1. **The matcher was injected into the installed plugin's `hooks.json`, not the
|
||
repository's.** The plan assumed the repo working tree *is* the active plugin
|
||
root. It is not: `~/.claude/plugins/cache/ktg-plugin-marketplace/voyage/5.9.1/`
|
||
is a plain directory holding its own copy, and that copy is what loads.
|
||
Editing `hooks/hooks.json` in the repo would have measured nothing. The cache
|
||
file was backed up, modified, and restored — verified byte-identical to the
|
||
repo file afterwards.
|
||
2. **The probe script lives under the session scratchpad, not `${TMPDIR}`.** A
|
||
pathguard hook refuses writes to `${TMPDIR}`. The load-bearing property was
|
||
only that the script sit **outside `hooks/scripts/`**, which
|
||
`tests/lib/doc-consistency.test.mjs:75-84` counts via `readdirSync`; the
|
||
scratchpad satisfies that just as well. The directory still holds 7 scripts.
|
||
|
||
### Probe hook
|
||
|
||
Logged every invocation as one JSON line (`tool_name`, `agent_id`,
|
||
`agent_type`, plus the untouched stdin) and always exited `0`, so it could not
|
||
alter the child's behaviour.
|
||
|
||
### Commands
|
||
|
||
```bash
|
||
# 1. inject the temporary matcher into the INSTALLED plugin
|
||
node -e '...push {matcher:"WebSearch", ...voyage-spike-hook.mjs} into hooks.PreToolUse...'
|
||
|
||
# 2. exercise it from a fresh child session
|
||
claude -p "Spawn exactly one sub-agent via the Agent tool (subagent_type: general-purpose).
|
||
Instruct that sub-agent to perform exactly ONE WebSearch for the query
|
||
'claude code hooks reference' and report back the first result title.
|
||
You MUST NOT call WebSearch yourself in the main context - only the
|
||
sub-agent may call it. When the sub-agent returns, reply with the word DONE." \
|
||
--allowedTools "Agent,Task,WebSearch" \
|
||
--max-turns 15
|
||
|
||
# 3. restore
|
||
cp "${TMPDIR}voyage-hooks-backup.json" <cache>/hooks/hooks.json
|
||
```
|
||
|
||
The child returned `DONE`.
|
||
|
||
> An earlier attempt additionally passed `--permission-mode bypassPermissions`
|
||
> and was refused by the auto-mode classifier. The flag was dropped;
|
||
> `--allowedTools` alone was sufficient.
|
||
|
||
## Raw observation
|
||
|
||
The log contains **exactly one** record — so the main context did not call
|
||
`WebSearch` itself, and the single entry is unambiguously the sub-agent's call:
|
||
|
||
```json
|
||
{"at":"2026-08-09T12:07:47.796Z","tool_name":"WebSearch",
|
||
"agent_id":"aa6d19525a4680fe0","agent_type":"general-purpose",
|
||
"raw_stdin":"{\"session_id\":\"b126fd6a-...\",\"cwd\":\"/Users/ktg/repos/ktg-plugin-marketplace/voyage\",
|
||
\"permission_mode\":\"auto\",\"agent_id\":\"aa6d19525a4680fe0\",\"agent_type\":\"general-purpose\",
|
||
\"effort\":{\"level\":\"xhigh\"},\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"WebSearch\",
|
||
\"tool_input\":{\"query\":\"claude code hooks reference\"},\"tool_use_id\":\"toolu_01Ka4...\"}"}
|
||
```
|
||
|
||
Both `agent_id` and `agent_type` are populated, matching the documented
|
||
common-input fields for sub-agent-originated tool events. A main-context call
|
||
would have carried neither.
|
||
|
||
## Consequence for Step 10
|
||
|
||
A plugin `PreToolUse` hook **does** observe sub-agent tool calls on CC 2.1.226,
|
||
and can attribute them via `agent_id` / `agent_type`. Step 10 may therefore take
|
||
the enforcement branch rather than the documented-downgrade branch.
|
||
|
||
Two limits worth carrying forward, neither of which changes the verdict:
|
||
|
||
- This measures `WebSearch` on one CC version. The behaviour regressed once
|
||
before (#34692), so the hook must fail **open**, never assume it is the only
|
||
gate, and the cap must remain correct if the hook silently stops firing.
|
||
- The probe only establishes *reach*. Whether a **blocking** (exit 2) decision
|
||
from inside a sub-agent propagates usefully was not measured — the probe
|
||
always exited 0 by design.
|
||
|
||
RESULT: FIRES
|
||
|
||
## Enforcement outcome
|
||
|
||
Step 10 took the **enforcement branch**: `hooks/scripts/pre-agent-cap.mjs`
|
||
(PreToolUse, matcher `WebSearch|WebFetch|Task`), pinned by
|
||
`tests/hooks/agent-cap.test.mjs`.
|
||
|
||
What it does: counts turns spent by a run — read-only, from the append-only
|
||
ledger that `lib/util/research-loop-cap.mjs` writes — and exits 2 once
|
||
`turns_used >= max_conv_turns × maxDimensions`. It never appends to the
|
||
ledger; a cap that recorded its own enforcement would count itself.
|
||
|
||
Scope key, the part that makes a globally-wired `PreToolUse` hook safe:
|
||
`session_id` **+** a marker file only the Phase 5 loop writes, at
|
||
`${CLAUDE_PLUGIN_DATA}/trekresearch-loop-scope/<session_id>.json`:
|
||
|
||
```json
|
||
{ "runId": "<run id>", "startedAt": "<ISO-8601>" }
|
||
```
|
||
|
||
No marker for the calling session ⇒ out of scope ⇒ allow, unconditionally.
|
||
An unrelated session is never denied because some other run spent its budget.
|
||
|
||
Fail-open and fail-closed are split deliberately:
|
||
|
||
| Condition | Outcome | Why |
|
||
|---|---|---|
|
||
| No marker / no `session_id` / unparsable stdin | allow | Not evidence of a loop turn |
|
||
| Marker older than TTL (default 6h, `VOYAGE_CAP_SCOPE_TTL_MS`) | allow + auto-reset | A crashed run must not deny tool calls forever |
|
||
| `VOYAGE_DISABLE_CAP_HOOK=1` | allow | Kill switch |
|
||
| `VOYAGE_STORM_ENABLED` ≠ `1` | allow | Default-off: no loop runs, nothing to enforce |
|
||
| In scope, `CLAUDE_PLUGIN_DATA` absent | **deny** | A budget control that cannot count must not grant — same stance as `research-loop-cap.mjs` |
|
||
| In scope, budget spent | **deny (exit 2)** | The bound |
|
||
|
||
Both limits recorded above still hold and are not closed by this step. Reach
|
||
was measured on one CC version for one tool, and blocking-propagation from
|
||
inside a sub-agent was never measured — so this hook is **defence in depth**,
|
||
and `research-loop-cap.mjs` must remain correct on its own if the hook
|
||
silently stops firing.
|
||
|
||
**Follow-up, closed (S79).** Step 10 shipped the hook correct but **latent** —
|
||
nothing wrote the marker, and it enforces exactly when a marker exists.
|
||
`commands/trekresearch.md` Phase 5 now writes it (`### Loop scope marker`,
|
||
keyed by `CLAUDE_CODE_SESSION_ID`, carrying the same `run_id` the ledger is
|
||
counted under) and removes it on all three exits. Cleanup covers exits only; a
|
||
crashed session is covered by the TTL above. Verified end-to-end: the snippet
|
||
as shipped arms the hook, the hook denies at `8/8` turns, and the removal
|
||
snippet returns it to allow so Phase 6 can still spawn agents. Pinned by
|
||
`tests/lib/doc-consistency.test.mjs` (STORM marker), which derives the
|
||
directory name from `SCOPE_DIRNAME` in the hook, so renaming either side
|
||
fails.
|