# The 4,000-Token Tax: Auto-Disabling Claude Code Plugins You Haven't Touched in 30 Days

> Source: <https://dev.to/bokuwalily/the-4000-token-tax-auto-disabling-claude-code-plugins-you-havent-touched-in-30-days-ac5>
> Published: 2026-08-22 11:00:06+00:00

Hitting ¥1.2M/month taught me something I didn't expect: keeping the environment healthy has to come *before* doing the work.

Adding a plugin to Claude Code takes a second. "Let's add context7." "Let's add playwright." "Might as well try expose too." Before I noticed, I had more than 50 plugins enabled. That's where the problem starts.

Every time Claude Code starts, a **deferred tools** section expands inside a `system-reminder`

block. It's the part that enumerates every tool name owned by the connected MCP servers. The more plugins you have, the more that section swells. playwright alone lines up close to 30 tool names, from `browser_click`

to `browser_wait_for`

.

This is a **quiet tax** on the context window. The first few thousand characters of input tokens in every single session disappear into a list of tool names that have nothing to do with the actual work. It shows up as degraded response accuracy early in a session, or as "that piece of information" getting pushed out at the end of a long conversation.

After half a year of building out an autonomous environment, I can say this with confidence: **leaving unused plugins enabled is not the same as leaving unused tools lying on your desk**. Tools only eat space. Plugins eat thinking budget in every session.

The root of the problem is the limit of manual management. Out of 50 plugins, it isn't realistic for a human to track "when did I last use this one?" So I built a system instead: **every Sunday morning, automatically disable any plugin that has had zero MCP calls and zero Skill calls for 30 days**.

Here's the real shape of the deferred tools that get expanded into `system-reminder`

.

```
The following deferred tools are now available via ToolSearch.
Their schemas are NOT loaded — calling them directly will fail
with InputValidationError.
Use ToolSearch with query "select:<name>[,<name>...]" to load
tool schemas before calling them:
mcp__plugin_playwright_playwright__browser_click
mcp__plugin_playwright_playwright__browser_close
mcp__plugin_playwright_playwright__browser_console_messages
...（30行以上続く）
```

That's playwright by itself. Add chrome-devtools, expo, and sequential-thinking, and the enumeration alone runs past 100 lines. At an average of 50 characters per line that's 5,000 characters — comfortably close to 4,000 tokens of fixed cost **every session**. Disabling unused plugins wipes that out entirely.

Back when I was a university student making ¥100k a month, I focused on "doing more work" to maximize side-income. Juggling gigs got me to ¥600k a month, but there was a hard ceiling. Time is finite, and so is stamina.

Then I was laid off, my income went to zero, and rebuilding over the next six months changed how I think. Once I had an autonomous environment in Claude Code, I understood that the key isn't "how well do I work" but "how well do I maintain the environment."

Automatic plugin management is part of that. Rather than spending 10 minutes a week deciding by hand which plugins to remove, it's far more reliable to **write that decision logic into a script and let it run autonomously on a weekly schedule**. Precisely because no human is involved, it never forgets. It never hesitates. It never takes a week off.

The whole thing consists of three components.

```
セッションJSONL群 (~/.claude/projects/)
      │
      ▼ (毎日 09:30)
┌─────────────────────────────┐
│  com.shun.plugin-usage      │  ← LaunchAgent①
│  plugin-usage.sh 14         │
│  → plugin-audit-latest.md   │
└─────────────────────────────┘
                                   ← レポートを人間が読む（任意）

セッションJSONL群 (~/.claude/projects/)
      │
      ▼ (毎週日曜 06:45)
┌─────────────────────────────┐
│  com.shun.plugin-auto-disable│  ← LaunchAgent②
│  plugin-auto-disable.sh apply│
│  → settings.json 書き換え   │
│  → キャッシュ削除           │
└─────────────────────────────┘
```

LaunchAgent ① generates a report every morning, and LaunchAgent ② performs the disabling weekly. Both run via `/bin/zsh`

and dump their results into `~/.claude/logs/`

.

The core of the report-generating script is counting tool_use events accurately out of the session JSONL.

```
find "$LOG_DIR" -maxdepth 1 -name "*.jsonl" -mtime -"$DAYS" -print0 2>/dev/null \
  | xargs -0 cat 2>/dev/null \
  | jq -R -r 'fromjson?
      | select(.type=="assistant")
      | .message.content[]?
      | select(.type=="tool_use")
      | if .name=="Skill"
        then ((.input.skill // "") | select(contains(":")) | split(":")[0])
        else (.name | select(startswith("mcp__plugin_"))
              | sub("^mcp__plugin_";"") | split("_")[0]) end' 2>/dev/null \
  | sort | uniq -c | sort -rn > "$TMP"
```

This jq pipeline does three things.

**First, it narrows to type=="assistant".** The records of Claude actually calling a tool live inside assistant messages. Other event types such as tool_result are excluded.

**Next, it targets only tool_use events.** `.message.content[]?`

expands each content block, and `select(.type=="tool_use")`

filters them. This is "the biggest difference from the grep implementation" (more on that later).

**Finally, it normalizes the plugin name.** For the Skill tool, it splits `input.skill`

on `:`

and takes the leading part (the plugin name). For MCP tools, it strips the `mcp__plugin_`

prefix and takes everything up to the next underscore. `mcp__plugin_playwright_playwright__browser_click`

yields `playwright`

.

After aggregation, it pulls the list of plugins with `enabled=true`

out of `enabledPlugins`

in `settings.json`

, and uses `comm -23`

to extract the ones that never appeared in the aggregation, displaying them as "Dormant."

```
ENABLED_LIST=$(jq -r '.enabledPlugins // {}
  | to_entries[]
  | select(.value)
  | .key' "$SETTINGS" 2>/dev/null \
  | awk -F@ '{print $1}' | sort -u)

comm -23 <(echo "$ENABLED_LIST") <(echo "$USED_LIST") | head -60
```

It also matters that `awk -F@ '{print $1}'`

drops the scope portion (`@scope`

). Even for plugins registered with a version, like `context7@1.0.0`

, the name portion alone still matches correctly.

This script runs via LaunchAgent **daily at 09:30**, overwriting the result into `~/.claude/scripts/plugin-audit-latest.md`

. The plist contents look like this.

```
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>9</integer>
  <key>Minute</key>
  <integer>30</integer>
</dict>
```

The absence of a `Weekday`

key is deliberate — that's what makes it run every day. When I want to see the report, I just open that md file.

The script that actually rewrites `settings.json`

has three safety layers.

**Layer 1: the Protected list**

Plugins that must never be removed, even at low usage, are explicitly excluded.

```
PROTECTED=(
  remember plugin-dev hookify skill-creator session-report
  security-guidance superpowers context7 explanatory-output-style
  learning-output-style code-review feature-dev claude-md-management
  # LSPs: Claude Code が透過的に呼び出す可能性。tool_use では現れない
  typescript-lsp pyright-lsp php-lsp ruby-lsp rust-analyzer-lsp swift-lsp
  # Process tools: ad-hoc に呼ばれる可能性
  code-simplifier code-modernization ralph-loop agent-sdk-dev mcp-server-dev
  playground commit-commands pr-review-toolkit
  # 既知の誤検出（過去のセッションでトラブル）
  azure-cosmos-db-assistant
)
```

The LSP entries matter most. `typescript-lsp`

and `pyright-lsp`

are invoked transparently by Claude Code internally, so they never appear as `tool_use`

events in the session logs. They're plugins that must stay enabled even with zero usage logs.

**Layer 2: the cache size filter**

Even when a 30-day zero-call candidate is found, anything with a cache size under `MIN_CACHE_MB=5`

MB is skipped.

```
for p in "${CANDIDATES[@]}"; do
  size=$(du -sm \
    "~/.claude/plugins/cache/claude-plugins-official/$p" \
    2>/dev/null | awk '{print $1}')
  size="${size:-0}"
  [ "$size" -lt "$MIN_CACHE_MB" ] && continue
  SIZED+=("${size}\t${p}")
done
```

Plugins with a small cache have a small impact on the system-reminder, so passing on them costs nothing. Conversely, prioritizing plugins with large caches produces the biggest context reduction per run. That's why `sort -rn`

orders by size descending and picks from the top.

**Layer 3: the weekly cap**

A single apply run disables at most `WEEKLY_MAX=5`

plugins.

```
SELECTED=()
if [ "${#SIZED[@]}" -gt 0 ]; then
  while IFS=$'\t' read -r sz p; do
    SELECTED+=("$p")
    [ "${#SELECTED[@]}" -ge "$WEEKLY_MAX" ] && break
  done < <(printf '%b\n' "${SIZED[@]}" | sort -rn)
fi
```

Dropping a lot at once makes "when did that plugin disappear?" hard to trace. The operating model is five per week, with a record left in `~/.claude/logs/plugin-auto-disable.log`

.

The actual disable operation is delegated to a separate script, `plugin-disable.sh`

. The point is to centralize `settings.json`

rewrites in one place.

This script runs automatically via LaunchAgent **every Sunday at 06:45**.

```
<key>StartCalendarInterval</key>
<dict>
  <key>Hour</key>
  <integer>6</integer>
  <key>Minute</key>
  <integer>45</integer>
  <key>Weekday</key>
  <integer>0</integer>
</dict>
```

`Weekday`

`0`

is Sunday. 6:45 in the morning is chosen because it's a time when the Mac is running and before work starts. Because of how launchd works, the schedule only fires while the Mac is up, so setting it for a time when the machine is off means it doesn't run until the following week.

Logs are written to two places: `~/.claude/logs/plugin-auto-disable.log`

, and `com.shun.plugin-auto-disable.log`

as specified in the plist's `StandardOutPath`

/ `StandardErrorPath`

(because the script does `tee -a "$LOGFILE"`

internally). Output looks like this.

```
[2026-07-13 06:45:01] auto-disable run (mode=apply, days=30)
[2026-07-13 06:45:03] dormant candidates: 12
[2026-07-13 06:45:03] selected for action (>=5MB, max 5): 5
  [APPLY] expo (47MB cache)
  [APPLY] sequential-thinking (23MB cache)
  [APPLY] drawio-skill (18MB cache)
  [APPLY] agent-eval (12MB cache)
  [APPLY] benchmarks (9MB cache)
[2026-07-13 06:45:07] applied disable for 5 plugin(s)
```

47MB + 23MB + 18MB + 12MB + 9MB = 109MB of cache freed in a single weekly run, and from the next session onward the deferred tools enumeration for those plugins is gone.

Every line of the jq pipeline quoted earlier has a purpose. Let me dig into the easy-to-miss parts in order.

First, the trailing `?`

on `fromjson?`

. That's jq's "error suppression operator," which silently discards lines that can't be parsed as JSON. Claude Code session files are in `.jsonl`

(JSON Lines) format, but there can be lines truncated mid-write — for example when the Mac goes to sleep during a write. With `fromjson`

(no `?`

), a single parse error halts the entire pipeline. With the `?`

, it runs straight through hundreds of thousands of JSONL lines without complaint.

Next, `select(.type=="assistant")`

. Events in the JSONL are split by the `type`

field into `user`

/ `assistant`

/ `tool_result`

and so on. The record of a tool being **called** exists only inside assistant messages. `tool_result`

contains the tool's response, but there are no `type=="tool_use"`

blocks in it. Without this filter, the content expansion downstream gets confused.

`.message.content[]?`

expands the array, and `select(.type=="tool_use")`

extracts only the actual tool-call blocks. This is the decisive difference from the old implementation, and the cause of the failure described later.

The Skill tool side is handled like this.

```
if .name=="Skill"
then ((.input.skill // "") | select(contains(":")) | split(":")[0])
```

`select(contains(":"))`

is a filter that only passes strings containing a colon. Claude Code skills are called in a form that joins the plugin name and the skill name with a colon, like `context7:query-docs`

. Built-in skills and invalid values have no colon, so this excludes them. `// ""`

is for null safety — if `input.skill`

doesn't exist it returns an empty string, which select then rejects.

The MCP tool side is as follows.

```
.name | select(startswith("mcp__plugin_"))
     | sub("^mcp__plugin_";"") | split("_")[0]
```

This extracts `playwright`

from a tool name like `mcp__plugin_playwright_playwright__browser_click`

. `sub`

(substitution) strips the prefix, then the remainder `playwright_playwright__browser_click`

is split on underscore and the first element is taken. There are currently no cases of plugin names containing internal underscores, but if this pattern ever breaks, this is where.

After aggregation, `comm -23`

extracts the dormant (zero-usage) plugins as a difference.

```
comm -23 <(echo "$ENABLED_LIST") <(echo "$USED_LIST")
```

`comm -23`

is a command that outputs "lines present only in file 1." However, it **assumes both lists are already sorted**. That's why ENABLED_LIST is always run through `sort -u`

at construction time, and USED_LIST is deduplicated with `sort -u`

after aggregation. Neglect this and `comm`

's output breaks.

`awk -F@ '{print $1}'`

exists to extract just the name portion from plugins registered with a version, like `context7@1.0.0`

. In Claude Code's settings.json, `enabledPlugins`

keys can take the `context7@1.0.0`

form. Using `@`

as the delimiter and taking only the first field lets it match correctly against `context7`

on the usage-log side.

`plugin-auto-disable.sh`

uses python3 to extract the list of enabled plugins.

``` python
ENABLED=$(python3 -c "import json; print('\n'.join(
  k.split('@')[0]
  for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items()
  if v))")
```

The same thing in jq would be `jq -r '.enabledPlugins // {} | to_entries[] | select(.value) | .key'`

(that's what plugin-usage.sh uses). Either is fine, but at the time I wrote auto-disable.sh I wanted to avoid the possibility that `select(.value)`

behaves subtly differently on falsy values (0 or an empty string) depending on the jq version, so I went with python3's `if v`

to drop falsy values explicitly. The judgment was that in a script that rewrites configuration, the risk of "selecting the wrong target" should be minimized.

The actual disable operation is delegated to `plugin-disable.sh`

.

```
"$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}"
```

Concentrating the `settings.json`

rewrite logic in one script prevents two places writing at once and corrupting the JSON. Whether it's the weekly auto-disable or a manual one-off disable, the write path is always and only here.

launchd **inherits none of your shell environment**. It reads neither `.zshrc`

nor `.zprofile`

. In other words, even if `jq`

, `python3`

, and `node`

work in your terminal, via launchd they run with only `/usr/bin:/bin`

on PATH. Homebrew's jq and nvm's node won't be found unless you tell it explicitly.

That's the role of `EnvironmentVariables`

. Both plists contain the following.

```
<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:
          /opt/homebrew/bin:/opt/homebrew/sbin:
          /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:
          ~/.local/bin</string>
</dict>
```

nvm's bin is placed first so that nvm always wins when versions get mixed with the system node. Homebrew `bin`

comes next.

Using `>`

and `>>`

differently for log output is also an important design choice. plugin-usage.plist's ProgramArguments looks like this.

```
<string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>
```

`>`

is the XML escape for `>`

. It **overwrites** (`>`

) every time. audit-latest.md is a file for viewing "the state at this exact moment," so a leftover previous report is meaningless. The plugin-auto-disable.plist command, on the other hand, **appends** with `>>`

. That's because a history of what was disabled and when is needed.

`ProcessType: Background`

tells launchd "no UI access needed, feel free to run in the background." With Interactive, there are cases where it won't run unless the user session is active. For a fully unattended weekly job, Background is the correct setting.

Both plists also specify `StandardOutPath`

/ `StandardErrorPath`

, but the `2>&1`

redirect inside ProgramArguments takes precedence. Effectively nothing gets written to the plist-side paths. It's redundant, but if I ever remove the command-side redirect in the future, the plist side functions as a fallback. It's a deliberate double structure.

When I first wrote plugin-usage.sh, I didn't use jq — I ran grep across all the JSONL files.

```
# 旧実装（動かない）
grep -rh "mcp__plugin_${plugin_name}" ~/.claude/projects/ | wc -l
```

After running it for a while and checking the aggregate results, a suspicious number showed up. **terraform was #1 in the usage ranking.** I have never used terraform once. On top of that, the usage count exceeded the number of enabled plugins, producing a "negative Dormant count." A minus that shouldn't be able to exist.

I figured out the cause when I looked directly at the JSONL contents. At the start of each session, Claude Code sends a `system-reminder`

saying "deferred tools are now available." Inside it is a plain-text **list of every tool name** owned by the enabled plugins.

```
mcp__plugin_terraform_terraform__workspace_list
mcp__plugin_terraform_terraform__resource_read
…（以下続く）
```

grep **was picking up that enumeration text**. One line of JSONL is a JSON object representing one event in the whole session. The system-reminder body sits in a text field inside it, and that body contained a pile of terraform tool names. Because grep searches as strings while ignoring JSON structure, it was miscounting "lines where the system-reminder enumerated tools" as "lines where a tool was called."

With 50 sessions, that's 50 enumerations. Dozens of enumerated lines hit per session, and terraform — which I'd never used — ended up recorded as called 1,000 times.

The fix is to walk the JSON structure properly with jq. The three-stage filter `select(.type=="assistant")`

→ `.message.content[]?`

→ `select(.type=="tool_use")`

extracts **only the records where Claude actually called a tool**. Text-type content blocks aren't type==tool_use, so the system-reminder enumeration never hits at all.

After the fix, terraform landed in the Dormant list (of course it did), and the negative Dormant count disappeared.

`status`

variable is read-only
When running things through LaunchAgent, I had written logic to emit a notification based on the script's exit code.

```
# 旧実装（zshで動かない）
plugin-disable.sh apply "${CANDIDATES[@]}"
status=$?
if [ "$status" -ne 0 ]; then
  echo "[ERROR] disable failed with code $status" | tee -a "$LOGFILE"
fi
```

Run manually in the terminal, everything works. Via LaunchAgent, no failure notification ever appears. No matter how much I stared at the logs, no error message was written even when the disable processing failed. Despite having `set -uo pipefail`

, the script kept going instead of stopping on the error.

The cause is a zsh specification. ** status is a zsh built-in read-only variable** that holds the exit code of the previous command. It's a variable bash doesn't have. Write

`status=$?`

and zsh `[ "$status" -ne 0 ]`

then references the current value of `status`

(the previous command's exit code), but since the assignment never took effect, it doesn't behave as intended.It's the trap you hit when you run a script written for `/bin/bash`

under zsh as-is. Because LaunchAgent's ProgramArguments used `/bin/zsh -c`

, it went unnoticed in the terminal (fish/bash startup) and misbehaved only under launchd's zsh.

The fix is just renaming the variable.

```
# 修正後
plugin-disable.sh apply "${CANDIDATES[@]}"
rc=$?
if [ "$rc" -ne 0 ]; then
  echo "[ERROR] disable failed with code $rc" | tee -a "$LOGFILE"
fi
```

`rc`

isn't reserved in zsh. That brought the failure notification back. Generally speaking, zsh scripts have many system variables bash doesn't — `status`

, `ARGC`

, `argv`

, `match`

, and more. Keep just `status`

in mind and you'll never hit this trap again.

When I first wrote the plist for the LaunchAgent, I didn't include `EnvironmentVariables`

. `jq --version`

works in the terminal, but via LaunchAgent the script immediately exits with "command not found: jq". Not even a log. The error that should have been written to `StandardErrorPath`

was blank, and `StandardOutPath`

was blank too. The script just went silent and ended.

launchd has only `/usr/bin:/bin:/usr/sbin:/sbin`

on PATH. Even though Homebrew puts binaries in `/opt/homebrew/bin`

and nvm in `~/.nvm/versions/node/v24.13.0/bin`

, launchd can't see them.

What made it worse: when `jq`

isn't found, under `set -uo pipefail`

the whole pipe is treated as a failure, nothing downstream runs, and the log-writing code never executes either. It merely *looks* like "nothing happened" — in reality it had died at the point jq wasn't found.

The fix is writing an explicit PATH into the plist.

```
<key>EnvironmentVariables</key>
<dict>
  <key>PATH</key>
  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:…</string>
</dict>
```

Reloading with `launchctl unload`

→ `launchctl load`

was the point at which jq was finally found and the script started working. As a rule for writing LaunchAgents, I now strictly ** which every command I use and write those paths into EnvironmentVariables**. jq, python3, node — every one of them lives somewhere different from a bare shell's view.

`wc -l`

returns spaces
This is the part of plugin-usage.sh that computes the Dormant count.

```
USED_COUNT=$(wc -l < "$TMP" | tr -d ' ')
DORMANT_COUNT=$(comm -23 … | wc -l | tr -d ' ')
```

The trailing `tr -d ' '`

wasn't there originally. macOS's `wc -l`

returns not `42`

but `42`

(space-padded on the left). GNU coreutils on Linux has no such padding; the BSD-derived macOS one does.

Put that output straight into a variable and embed it in Markdown, and the header line gets written out with stray spaces, like `- Dormant (enabled but never invoked): ** 12**`

. It only breaks the appearance and doesn't affect the arithmetic — but later, when I added code to compare the value numerically, `[ "$DORMANT_COUNT" -gt 30 ]`

stopped evaluating correctly. The string `" 12"`

is the same as `"12"`

in an arithmetic comparison, but behavior varies subtly by environment.

Inserting `tr -d ' '`

solves it completely. It's a staple step when writing Bash scripts on macOS, but bring a script raised on Linux over as-is and you'll hit it every time.

All four of these failures presented as either "works in the terminal but not under LaunchAgent" or "numbers come out but they're meaningless." What they share is **differences in the execution environment**. Differences in shell (bash vs zsh), PATH (interactive shell vs launchd), and OS (Linux vs macOS) invalidate code that looks correct on the page. The value of LaunchAgent wiring lies in full unattended operation — but that's exactly what makes it an area where "bugs you'd never notice manually" hide. The design of writing logs twice (script-internal `tee -a`

plus the plist's `StandardErrorPath`

) is also a countermeasure against this "fail silently" pattern.

Beyond the four failures covered above (grep ghosts, the `status`

variable, launchd PATH, and `wc -l`

padding), there were other points where the actual wiring tripped me up. Each one has real code behind it.

**Without printf '%b\n', the tab stays a literal string.** On lines 85–90 of

`plugin-auto-disable.sh`

, the cache size and plugin name are pushed into an array as `SIZED+=("${size}\t${p}")`

, but in a single-quoted context that `\t`

is two characters, backslash and t. Only by using the `%b`

format in `printf '%b\n' "${SIZED[@]}" | sort -rn`

on line 96 does it expand into a tab. Use `echo`

or `printf '%s\n'`

and `sort -rn`

fails to recognize the fields correctly, breaking the descending size sort.`comm -23`

breaks silently unless both lists are sorted.`comm`

assumes its inputs are sorted in lexicographic order. Hand it unsorted input and `comm`

raises no error — the output is simply undefined. That's why the design shapes `ENABLED_LIST`

with `sort -u`

and runs `USED_LIST`

through `sort -u`

as well. Test with `sort`

omitted and plugins that should appear in Dormant don't, or used plugins get mixed into Dormant.

**The -u flag of set -uo pipefail kills the script on unbound variables.**

`plugin-auto-disable.sh`

starts with `set -uo pipefail`

. `-u`

turns references to uninitialized variables into errors. Referencing `${#SIZED[@]}`

while the `SIZED`

or `SELECTED`

arrays are still empty is fine, but forget an initial value when adding a variable later and it exits immediately with `unbound variable`

. Via LaunchAgent, the error log easily ends up in a "the command was launched but there's no output" state, so any variable I add always gets a default value (`VAR="${VAR:-default}"`

).**Without trap 'rm -f ...' EXIT, tmp files linger.** Line 59 of

`plugin-auto-disable.sh`

has `trap 'rm -f "$TMP" "$SKILL_TMP"' EXIT`

. Under `set -uo pipefail`

, when the script terminates midway, tmp files stay behind in `/tmp/`

. LaunchAgent runs the same script weekly, so they pile up and gradually pollute `/tmp`

. `trap`

is mandatory as a line of defense.**Forget the plist's XML escaping and launchctl refuses to load it.** Line 18 of `plugin-usage.plist`

is this.

```
  <string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>
```

Unless `>`

is escaped as `>`

and `&`

as `&`

, `launchctl load`

returns a `Format error`

at the point of parsing the plist as XML and silently does nothing. Accidentally introducing a raw `>`

while editing is not an unusual mistake. Getting into the habit of verifying beforehand with `plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist`

prevents it.

**Not being aware that Weekday=0 is Sunday means it runs on Monday.** launchd's

`Weekday`

key is zero-based: 0=Sunday, 1=Monday. If you want the weekly disable to "run Monday morning," setting `Weekday=1`

is correct — but misread it as `0=Monday`

and set `Weekday=0`

, and it runs on Sunday. The practical harm is small, but you'll be confused looking at logs showing "it ran on Sunday for some reason." The `Weekday=0`

(Sunday) / `Hour=6`

/ `Minute=45`

setting in `com.shun.plugin-auto-disable.plist`

is intentional: it's so the disabling is already done before work starts on Monday.**If the Mac is asleep at the StartCalendarInterval time, it doesn't run.** Unlike cron, launchd doesn't queue up runs missed during sleep. Set it to 06:45 and, with the Mac closed, that week's run happens. On Macs with PowerNap enabled, background processing can run during sleep, but there's no guarantee. If you need certainty, prevent sleep with

`caffeinate -s`

, or move the run time to a window when the Mac is definitely up (like 09:30). The daily report generation (09:30) runs reliably precisely because it's during working hours.**If plugin-disable.sh doesn't exist, the script stops during apply.** Line 121 of

`plugin-auto-disable.sh`

calls `"$HOME/.claude/scripts/plugin-disable.sh" apply "${CANDIDATES[@]}"`

. If that downstream script doesn't exist or isn't executable, `set -uo pipefail`

ends the entire script right there, and the subsequent `echo "[$(ts)] applied disable"`

log never appears. "I ran apply but there's no completion record in the log" is this pattern. Checking `ls -la ~/.claude/scripts/plugin-disable.sh`

and `chmod +x`

comes first.**Expansion accidents when embedding shell variables into python3's -c inline script.** Line 66's

`python3 -c "import json; print('\n'.join(k.split('@')[0] for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items() if v))"`

has `$SETTINGS`

expanded by the shell inside double quotes. If the path contains a space, python3 raises a parse error. The real path of `~/.claude/settings.json`

has no spaces so it's not a problem today, but if the design takes the path from a variable, it's safer to quote `$SETTINGS`

separately or pass it via a heredoc.**Without restarting Claude Code after disabling, the deferred tools section isn't updated.** Rewriting `settings.json`

takes effect immediately, but an already-running Claude Code session retains the settings it loaded at startup. Even after the weekly disable runs, that day's session still expands the deferred tools section in the pre-disable state. Getting the context reduction requires starting the next session. That's one reason the automation is scheduled for early morning: if the disabling is finished before work starts, the effect is there from the first session of the day.

Reproducible principles distilled from actually running this.

**1. Make MODE=dry the default and always confirm with a dry-run before apply**

Argument handling on line 1 of `plugin-auto-disable.sh`

is `MODE="${1:-dry}"`

. Call it with no arguments and you get a dry-run. In the first week after wiring something new, I first check the breakdown of CANDIDATES and the SIZED/SELECTED selection results with a dry-run, confirm no unintended plugin got picked, and only then configure the LaunchAgent to apply.

**2. Write the reason in a comment on the Protected list**

The PROTECTED list on lines 26–37 of `plugin-auto-disable.sh`

has a comment on each group explaining why it's protected. Without the line "LSPs: Claude Code may invoke these transparently; they don't appear in tool_use," typescript-lsp would look Dormant every time and keep coming up as a deletion candidate. A list with no reasons gives you no basis for cleanup decisions three months later.

**3. Prevent over-disabling with the two-stage filter WEEKLY_MAX=5 and MIN_CACHE_MB=5**

Dropping a lot of plugins at once makes "what disappeared" hard to trace. Caches under 5MB have negligible impact on deferred tools, so they're excluded, and the constraint of 5MB-or-more with a max of five gives reliable control. When I want to drop a lot in a hurry, I can override via environment variable, like `WEEKLY_MAX=20 plugin-auto-disable.sh apply`

.

**4. Write the results of which <command> straight into the launchd PATH**

For `jq`

, `python3`

, and `node`

alike, where they work in the terminal and what the launchd environment searches are different things. The PATH written into the plist should include every directory found by checking `which jq`

and `which python3`

for the commands you use. If you use nvm, that's `/Users/<username>/.nvm/versions/node/vX.Y.Z/bin`

; for Homebrew, put `/opt/homebrew/bin`

near the front. The actual setting in `com.shun.plugin-auto-disable.plist`

is, in order: nvm v24.13.0 bin → opt/homebrew/bin → homebrew/sbin → usr/local/bin → usr/bin → bin → sbin → .local/bin.

**5. Give logs a double structure: script-internal tee -a plus the plist's StandardErrorPath**

The main log output of `plugin-auto-disable.sh`

is appended to `~/.claude/logs/plugin-auto-disable.log`

via `| tee -a "$LOGFILE"`

. The plist's `StandardErrorPath`

points somewhere else. When "the command crashed and only stderr came out," you look at the plist-side log; when "the script ran and I want the processing record," you look at the tee-side log. The roles are split. Doubling up debugs faster than unifying them.

**6. Always include set -uo pipefail to kill silent failures**

Unlike the terminal, scripts run via LaunchAgent show the user nothing when they fail. Without `set -uo pipefail`

, even if `jq`

isn't found, everything downstream continues and it looks like "nothing happened." Adding `-uo pipefail`

makes both mid-pipe failures and non-zero exit codes terminate the script immediately. The error gets written to the log file specified in `StandardErrorPath`

, so the cause of failure is preserved.

**7. Always validate the plist with plutil -lint before loading**

```
plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist
```

If there's no problem, it returns `com.shun.plugin-auto-disable.plist: OK`

. Missing XML escapes and forgotten dict/array closing tags get caught here. `launchctl load`

can fail silently on parse errors, so lint first, then load.

**8. Strictly do launchctl unload → launchctl load to apply plist changes**

Edit a plist and an already-loaded job still won't run with the new settings. Every time you change it, reinstall in the order `launchctl unload ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist`

→ `launchctl load ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist`

. Some articles recommend `launchctl bootout`

/ `launchctl bootstrap`

on macOS Ventura and later, but for user-scope LaunchAgents `unload`

/`load`

continues to work.

**9. Run plugin-usage.sh manually once and check the aggregation before leaving it to LaunchAgent**

```
~/.claude/scripts/plugin-usage.sh 14
```

Run that directly and eyeball the Dormant list. If you see "there's a plugin I don't recognize," "the Dormant count is negative," or "a plugin I definitely used is marked unused," then there's a problem in one of: the jq pipeline, settings.json's enabledPlugins, or the LOG_DIR path. Hand it to LaunchAgent without a manual check and you won't notice a malfunction until the weekly run.

**10. Always chain tr -d ' ' after wc -l**

macOS's `wc -l`

puts spaces to the left of the number. In an environment where `wc -l < "$TMP"`

returns `12`

, embedding that value directly in Markdown breaks the appearance and produces undefined behavior in numeric comparisons. The small extra step of `wc -l < "$TMP" | tr -d ' '`

prevents later bugs. It's the classic landmine when bringing a script written for GNU coreutils onto macOS.

**11. Make is_protected() judge by exact match, not partial match**

The `is_protected()`

function in `plugin-auto-disable.sh`

uses the exact comparison `[ "$p" = "$x" ]`

. That's to distinguish `code-review`

from `code-reviewer`

. Make it a partial match with something like `grep -q "$x"`

, and when `code-review`

is Protected, `code-reviewer`

becomes protected too. Plugin names are often similar, so exact match is the safe choice.

**12. Deliberately offset the windows for the daily audit (14 days) and the weekly disable (30 days)**

`plugin-usage.sh`

's default `DAYS=14`

and `plugin-auto-disable.sh`

's `DAYS=30`

are set for different reasons. The audit is for looking at activity over the last two weeks, daily; the disable is executed only after gaining confidence that something hasn't been called at all for over a month. Set the audit side to 30 days and you'd be staring at a "monthly trend" every day, which makes change harder to see. The separation of audit=short, disable=long is intentional.

**13. After disabling, verify the deferred tools line count for those plugins in the next session**

Check at session start, via the system-reminder, how much the deferred tools section actually shrinks per weekly apply. In the 2026-07-13 run log, five plugins were disabled: expo (47MB), sequential-thinking (23MB), drawio-skill (18MB), agent-eval (12MB), and benchmarks (9MB). Just as playwright enumerates 30 tool names, these too hold dozens to nearly a hundred lines in the system-reminder in total. Confirming the reduction in numbers is what makes the system's effectiveness tangible.

Automatic plugin management isn't an optimization of "which tools to use." It's a fix for the structural problem that "tools you don't use erode the top of every session."

`plugin-usage.sh`

counts only actual tool_use events with jq; `plugin-auto-disable.sh`

passes through three stages of safety valves — Protected exclusions, the cache size filter, and the five-per-week cap — before rewriting settings.json; and two LaunchAgents run it completely unattended at 09:30 daily and 06:45 every Sunday. Since this wiring was finished, the problem of "adding plugins and leaving them there" has vanished from my mind.

Counting ghosts with grep. Stepping on zsh's `status`

variable. Forgetting to give launchd a PATH. macOS's `wc -l`

returning spaces. Every one of them was a "it worked in the terminal but not under LaunchAgent" failure. Failures like these accumulate into a deeper understanding of what it means to cultivate an environment.

A ¥1.2M/month autonomous environment is built less from flashy features than from continually shaving away invisible friction. Plugin management is only one piece of that, but protecting the top of the context in every session bears directly on Claude Code's quality of judgment.

I've written up the full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day process in a paid note.

📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)

*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.

Follow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*
