Claude Code 工作階段曾超出 10 個子智能體上限,加入 PreToolUse hook 後 676 次啟動峯值為 10 個
One Claude Code session ran 15 subagents under our 10-subagent rule. After a PreToolUse hook, 676 launches peaked at 10
Rulestack 為其 Claude Code 工作階段設置 PreToolUse hook,限制每個工作階段最多同時執行 10 個子智能體;加入後 676 次啟動的最高並發數為 10 個。
For eight weeks our limit of 10 concurrent Claude Code subagents was a sentence in CLAUDE.md, and one session reached 15. A PreToolUse hook on the Agent tool now refuses the 11th by counting the subagents still running in the session plus the launches already placed in the same response. Across 676 launches since, the peak has been exactly 10. Its first four refusals were wrong, and Claude Code's own subagent limit would not have caught our 15.
A cap that was only a sentence
On 2026-08-03 our agent started 56 subagents at once for a weekly review, and the Mac it runs on ran short of memory. The owner capped parallel subagents at 30 that day and lowered the cap to 10 the next day. Our CLAUDE.md recorded it as a hard cap: never more than 10 at a time, counting every kind of agent together; bigger plans run in waves, and before each launch the agent adds what is running to what it is about to start.
For the next eight weeks that sentence was the only enforcement, and most of the time it was enough. We still have local transcripts for 16 sessions from before the hook. Fifteen of them never had more than 10 subagents running. The sixteenth reached 15 at 01:28 UTC on 2026-09-26.
Nobody decided to run 15. The session had six agent-team teammates researching one topic each. Two of the teammates started helpers of their own, and one helper split its batch into four more readers. Two responses did most of the climb. A teammate put four Agent calls into one response while seven agents were already running, which made eleven. About ninety seconds later, one of those four helpers put four more Agent calls into a single response, which made fifteen. Each step looked fine from where it was taken. The rule asked whoever launched to count the whole session first, and both responses launched anyway.
On 2026-09-30 we wrote this up as an item on our to-automate list, and the same day commit 9d5b284b added the hook.
Why the stop has to be a PreToolUse hook
The Claude Code hooks reference gives two candidate events, and only one of them can say no. Exit code 2 from a PreToolUse hook blocks the tool call, and Claude receives the hook's stderr as the tool's error. Exit code 2 from SubagentStart does not stop anything: the stderr is shown as a hook error notice, Claude doesn't see it, and the subagent proceeds. A cap has to act before the Agent call runs, so it lives on PreToolUse.
Here is the registration from .claude/settings.json, trimmed to the one group. The matcher names both Agent and Task, because the docs say the Task tool was renamed to Agent in version 2.1.63. The group also runs a second, unrelated hook.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Agent|Task",
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/require-cross-model-reviewer.sh\""
},
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR/scripts/hooks/limit-concurrent-subagents.sh\""
}
]
}
]
}
}
The shell script is a thin wrapper. It hands the hook's stdin to a TypeScript CLI, src/commands/subagent-concurrency-gate.ts. It exits 2 only when the CLI exits 2, and it exits 0 in every other case, including when the CLI is missing or crashes. When the cap is reached, the CLI prints a refusal. Translated and trimmed from the Japanese it prints: "This session has 10 subagents running at once (10 running + 0 waiting to start). The cap is 10. Wait for a running subagent to finish, then launch again (split larger plans into waves)."
What "running" means to the hook
The hook input carries session_id, transcript_path and tool_use_id. The docs say a subagent's own transcript is stored in a nested subagents/ folder. On our disk that folder sits beside the main transcript, in a directory named after the session, and each subagent leaves two files in it: a transcript, agent-<id>.jsonl, and a meta.json whose toolUseId is the id of the Agent call that started it. Neither file's format is documented, so everything below is what we observed, not a spec.
The obvious shortcut would be to count Agent calls in the parent transcript that haven't returned yet. That count would be close to zero. Of the 1,125 subagent launches we analyzed (the window is described below), 1,106 have a meta.json that records the launch as background. For 625 of the 637 background launches after the hook went in, the parent's Agent call got its result within one second of the meta.json appearing. The subagent then kept working for a median of 328 s, and 2,477 s at the 90th percentile. The docs say a background subagent's results reach Claude as a completion notification in a later turn. So the Agent call closes almost immediately, and the work goes on long after it.
The hook therefore reads each subagent's own transcript and looks only at its last line. A subagent counts as finished if that line is one of three things. It can be an assistant line whose stop_reason is end_turn, stop_sequence or refusal. It can be the [Request interrupted by user marker. Or it can be a tool result containing Report delivered to your caller., which was added later and is explained below. Anything else counts as running, unless the transcript hasn't been written to for 30 minutes. That timeout is the escape hatch for a subagent that crashed on a line we don't recognize. When the hook was built, the rule was checked against the 136 subagent transcripts of the session that built it: 130 ended in end_turn, 4 in stop_sequence, 1 in the interrupt marker, and 1 on a text-only line with no stop reason, which the 30-minute rule has to handle.
Two choices follow from the 15. If the transcript path the hook receives is itself inside a subagents/ folder, meaning a subagent is launching, the hook counts that same folder. A helper's own launches therefore count against the session's total. Teammates' transcripts sit in the same folder, so teammates count too, because the original rule counted every kind of agent together.
export function countConcurrentSubagents({ subagents, reservations, now }) {
const staleBefore = now.getTime() - RUNNING_SUBAGENT_STALE_MINUTES * 60 * 1000
const runningSubagentCount = subagents.filter(
(subagent) =>
!subagent.finished &&
(subagent.transcriptUpdatedAt ?? subagent.startedAt).getTime() >= staleBefore,
).length
const materializedToolUseIds = new Set(
subagents.map((subagent) => subagent.toolUseId).filter((id) => id !== null),
)
const reservationExpiredBefore = now.getTime() - SPAWN_RESERVATION_TTL_SECONDS * 1000
const pendingReservationCount = new Set(
reservations
.filter(
(reservation) =>
reservation.reservedAt.getTime() >= reservationExpiredBefore &&
!materializedToolUseIds.has(reservation.toolUseId),
)
.map((reservation) => reservation.toolUseId),
).size
return {
runningSubagentCount,
pendingReservationCount,
total: runningSubagentCount + pendingReservationCount,
}
}
(Trimmed from src/ops/subagent-concurrency.ts, with the types removed.)
Why launches in the same response have to be counted
Counting files on disk leaves a gap. When a response contains several Agent calls, the earlier subagents in that response don't exist on disk yet when the hook checks the later calls. Here is one response from 2026-10-01 that started six reviewers:
The pattern holds across our transcripts. After the hook went in, 197 responses placed two or more Agent calls, 521 calls in all. We looked at 295 back-to-back pairs where the earlier call did produce a subagent. In every one of them, the earlier subagent's meta.json appeared after the later call had already been written, a median of 1.11 s later. In all eight responses with six or more launches, the first meta.json appeared 0.58 to 1.85 s after the last call.
The hook's reservation file (described next) keeps only recent entries, so we have just a few readings from the hook's side, and they show both orders. In one response, four hooks ran back to back within 0.64 s after the last call was written, and the first subagent's meta.json appeared between the second and third hook. In another, each hook ran 0.5 to 1.8 s after its own call, while the model was still writing the next one.
So every hook that lets a launch through also writes a reservation, the tool_use_id plus a timestamp, to a small JSONL file in the temp directory. A directory lock guards the file: up to 3 s of waiting, and a lock older than 10 s is treated as abandoned. A reservation counts as pending for up to 120 s, until a meta.json with the same toolUseId shows up. It is matched by id, so a launch that has appeared is counted once, not twice.
To be honest about it: the reservation path has never been the reason for a real refusal. The 2026-09-26 climb included exactly the case it exists for, seven running plus four in one response. Since the hook went in, our agent hasn't produced that case again. In all ten real refusals, the pending count was zero.
The first four refusals were wrong
At 16:12 UTC on 2026-10-01, the agent placed four reviewer launches in one response, and the hook refused all four: "10 running + 0 waiting." Our transcript intervals put the real number at two. The other eight had finished by calling SubagentHandback, the tool a subagent uses to hand its report to the agent that launched it. A transcript that ends that way doesn't end in end_turn. Its last line is the tool's result, "Report delivered to your caller." We had checked the rule against 136 transcripts, and none of them ended this way, so the hook would have counted those eight as running for 30 minutes.
The agent read the hook's source, added the third finish marker, and launched the same four reviewers again at 16:14:44, two and a half minutes after the first refusal. The fix was committed as 290035eb at 16:19:03. The rule leans this way on purpose. An unrecognized last line counts as running, because over-counting costs a few minutes of waiting, while under-counting breaks the cap. Here the cost was four reviews that started two and a half minutes late.
The six refusals that were right
At 07:46 UTC on 2026-10-12, a session was running ten subagents that were writing tests. One of them, partway through its chunk, put six Agent calls into one response to split the rest of its work. The hook refused all six: "10 running + 0 waiting." The subagent wrote its chunk itself. Without the hook, that session would have reached 16, much like the 15 from September.
These refusals took between 0.96 and 3.22 s each, on a machine busy with ten test-writing agents. The four on 2026-10-01 took 0.43 to 0.46 s.
Before and after
To compare, we scanned every subagent transcript we still have locally: 1,125 launches in 20 sessions, from 2026-09-13 to 18:00 UTC on 2026-10-13. Each subagent's lifetime runs from the first to the last timestamp in its transcript. At each launch, we counted how many lifetimes in the same session covered that moment. "Before" means launches before the hook's commit at 09:32 UTC on 2026-09-30.
Two things keep this from being a cleaner win than it is. First, the "before" side is one event, not a habit. All five launches that landed with more than ten running came from that single climb on 2026-09-26, and if you leave teammates out, the peak before the hook was 10, not 15. Second, the hook doesn't plan waves. The agent does that, because the rule tells it to. After the hook, 55 launches landed with exactly ten running, counting themselves, and 47 of those came from a response that added one subagent to nine already running. The hook is the backstop under that planning. In 676 launches it has refused on two occasions, one of them by mistake.
Claude Code's own limit
Claude Code has a concurrency limit of its own. According to the subagents docs, once 20 subagents are running in a session, a further Agent call fails with Concurrent subagent limit reached. CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS changes the number, the limit needs version 2.1.217 or later, and agent-team teammates "follow their own limits instead". Resuming a finished subagent takes a slot without checking the limit.
Our transcripts show version 2.1.281 on 2026-09-26, so that default of 20 was in force during the climb, and 15 never reached it. Setting the variable to 10 would not have caught it either. By the documented rule, six of the fifteen were teammates and don't count, which leaves at most nine. Nothing in our repository sets the variable. We keep snapshots of the Claude Code docs in the repository, and the snapshot that first contained this section was committed on 2026-09-27 (UTC), three days before the hook. Neither commit that built or fixed the hook mentions it. If your fan-out never uses teammates, setting that variable to your number is much less code than ours.
The tests
Five test files cover the hook, 142 tests in all. Running just those files, they passed in 91.7 s. The original file, test/ops/subagent-concurrency.test.ts, has 21 tests. Besides the pure counting functions, its hook cases run the real bash hook against fake session folders: nine running lets the tenth through and leaves one reservation; nine running plus that reservation refuses the eleventh in the same response, with the message "9 running + 1 waiting"; ten running refuses the eleventh; a non-Agent tool or broken JSON passes; and the settings file still registers the hook on the Agent matcher. The SubagentHandback fix added one case. The other four files were added between 2026-10-08 and 2026-10-12 to freeze the hook's behavior before a larger refactor. They pin the lock timings, where the reservation file lives, the 4 MiB tail read of each transcript, and the decision to let a launch through whenever the CLI fails.
What it doesn't do
- It is per session. Three sessions can each run ten. Memory was the reason for the cap, and memory is shared by the whole machine.
-
It fails open, and silently. A missing
tsx, unreadable input, or a lock it can't get within 3 s all let the launch through. The hooks docs say Claude never sees stderr from a hook that exits 0, so nobody hears about it. - Thirty minutes is a guess. Six of the 1,125 transcripts went quiet for more than 30 minutes in the middle of a run, for between 33 minutes and 6.5 hours. Once a silence passed the 30-minute mark, the hook would have stopped counting that subagent until it wrote again.
-
It reads an undocumented format.
meta.json,toolUseIdand the shapes of last lines are things we observed, and one new way of finishing has already broken the hook once.
What we'd like to know
- Do you cap concurrent subagents at all, or does the default of 20 never bind for you?
- If you cap, where: the environment variable, a hook, or just an instruction in CLAUDE.md?
- Do your teammates and nested subagents share one budget with everything else, or does each layer get its own?
- What does your gate do when it can't read its input, let the launch through or stop it?
If you have ever watched a fan-out of agents climb past the number you meant to allow, we'd like to hear how far it went and what stopped it, in the comments below. We'll answer each one there.
來源:DEV Community · dev.to

