{"slug": "the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30", "title": "The 4,000-Token Tax: Auto-Disabling Claude Code Plugins You Haven't Touched in 30 Days", "summary": "A developer who hit ¥1.2M/month in revenue built an autonomous system to auto-disable unused Claude Code plugins, addressing a context-window tax that costs up to 4,000 tokens per session. The system runs weekly via LaunchAgents, disabling plugins with zero MCP or Skill calls for 30 days, and the developer reports improved session performance and reliability.", "body_md": "Hitting ¥1.2M/month taught me something I didn't expect: keeping the environment healthy has to come *before* doing the work.\n\nAdding 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.\n\nEvery time Claude Code starts, a **deferred tools** section expands inside a `system-reminder`\n\nblock. 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`\n\nto `browser_wait_for`\n\n.\n\nThis 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.\n\nAfter 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.\n\nThe 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**.\n\nHere's the real shape of the deferred tools that get expanded into `system-reminder`\n\n.\n\n```\nThe following deferred tools are now available via ToolSearch.\nTheir schemas are NOT loaded — calling them directly will fail\nwith InputValidationError.\nUse ToolSearch with query \"select:<name>[,<name>...]\" to load\ntool schemas before calling them:\nmcp__plugin_playwright_playwright__browser_click\nmcp__plugin_playwright_playwright__browser_close\nmcp__plugin_playwright_playwright__browser_console_messages\n...（30行以上続く）\n```\n\nThat'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.\n\nBack 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.\n\nThen 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.\"\n\nAutomatic 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.\n\nThe whole thing consists of three components.\n\n```\nセッションJSONL群 (~/.claude/projects/)\n      │\n      ▼ (毎日 09:30)\n┌─────────────────────────────┐\n│  com.shun.plugin-usage      │  ← LaunchAgent①\n│  plugin-usage.sh 14         │\n│  → plugin-audit-latest.md   │\n└─────────────────────────────┘\n                                   ← レポートを人間が読む（任意）\n\nセッションJSONL群 (~/.claude/projects/)\n      │\n      ▼ (毎週日曜 06:45)\n┌─────────────────────────────┐\n│  com.shun.plugin-auto-disable│  ← LaunchAgent②\n│  plugin-auto-disable.sh apply│\n│  → settings.json 書き換え   │\n│  → キャッシュ削除           │\n└─────────────────────────────┘\n```\n\nLaunchAgent ① generates a report every morning, and LaunchAgent ② performs the disabling weekly. Both run via `/bin/zsh`\n\nand dump their results into `~/.claude/logs/`\n\n.\n\nThe core of the report-generating script is counting tool_use events accurately out of the session JSONL.\n\n```\nfind \"$LOG_DIR\" -maxdepth 1 -name \"*.jsonl\" -mtime -\"$DAYS\" -print0 2>/dev/null \\\n  | xargs -0 cat 2>/dev/null \\\n  | jq -R -r 'fromjson?\n      | select(.type==\"assistant\")\n      | .message.content[]?\n      | select(.type==\"tool_use\")\n      | if .name==\"Skill\"\n        then ((.input.skill // \"\") | select(contains(\":\")) | split(\":\")[0])\n        else (.name | select(startswith(\"mcp__plugin_\"))\n              | sub(\"^mcp__plugin_\";\"\") | split(\"_\")[0]) end' 2>/dev/null \\\n  | sort | uniq -c | sort -rn > \"$TMP\"\n```\n\nThis jq pipeline does three things.\n\n**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.\n\n**Next, it targets only tool_use events.** `.message.content[]?`\n\nexpands each content block, and `select(.type==\"tool_use\")`\n\nfilters them. This is \"the biggest difference from the grep implementation\" (more on that later).\n\n**Finally, it normalizes the plugin name.** For the Skill tool, it splits `input.skill`\n\non `:`\n\nand takes the leading part (the plugin name). For MCP tools, it strips the `mcp__plugin_`\n\nprefix and takes everything up to the next underscore. `mcp__plugin_playwright_playwright__browser_click`\n\nyields `playwright`\n\n.\n\nAfter aggregation, it pulls the list of plugins with `enabled=true`\n\nout of `enabledPlugins`\n\nin `settings.json`\n\n, and uses `comm -23`\n\nto extract the ones that never appeared in the aggregation, displaying them as \"Dormant.\"\n\n```\nENABLED_LIST=$(jq -r '.enabledPlugins // {}\n  | to_entries[]\n  | select(.value)\n  | .key' \"$SETTINGS\" 2>/dev/null \\\n  | awk -F@ '{print $1}' | sort -u)\n\ncomm -23 <(echo \"$ENABLED_LIST\") <(echo \"$USED_LIST\") | head -60\n```\n\nIt also matters that `awk -F@ '{print $1}'`\n\ndrops the scope portion (`@scope`\n\n). Even for plugins registered with a version, like `context7@1.0.0`\n\n, the name portion alone still matches correctly.\n\nThis script runs via LaunchAgent **daily at 09:30**, overwriting the result into `~/.claude/scripts/plugin-audit-latest.md`\n\n. The plist contents look like this.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n  <key>Hour</key>\n  <integer>9</integer>\n  <key>Minute</key>\n  <integer>30</integer>\n</dict>\n```\n\nThe absence of a `Weekday`\n\nkey is deliberate — that's what makes it run every day. When I want to see the report, I just open that md file.\n\nThe script that actually rewrites `settings.json`\n\nhas three safety layers.\n\n**Layer 1: the Protected list**\n\nPlugins that must never be removed, even at low usage, are explicitly excluded.\n\n```\nPROTECTED=(\n  remember plugin-dev hookify skill-creator session-report\n  security-guidance superpowers context7 explanatory-output-style\n  learning-output-style code-review feature-dev claude-md-management\n  # LSPs: Claude Code が透過的に呼び出す可能性。tool_use では現れない\n  typescript-lsp pyright-lsp php-lsp ruby-lsp rust-analyzer-lsp swift-lsp\n  # Process tools: ad-hoc に呼ばれる可能性\n  code-simplifier code-modernization ralph-loop agent-sdk-dev mcp-server-dev\n  playground commit-commands pr-review-toolkit\n  # 既知の誤検出（過去のセッションでトラブル）\n  azure-cosmos-db-assistant\n)\n```\n\nThe LSP entries matter most. `typescript-lsp`\n\nand `pyright-lsp`\n\nare invoked transparently by Claude Code internally, so they never appear as `tool_use`\n\nevents in the session logs. They're plugins that must stay enabled even with zero usage logs.\n\n**Layer 2: the cache size filter**\n\nEven when a 30-day zero-call candidate is found, anything with a cache size under `MIN_CACHE_MB=5`\n\nMB is skipped.\n\n```\nfor p in \"${CANDIDATES[@]}\"; do\n  size=$(du -sm \\\n    \"~/.claude/plugins/cache/claude-plugins-official/$p\" \\\n    2>/dev/null | awk '{print $1}')\n  size=\"${size:-0}\"\n  [ \"$size\" -lt \"$MIN_CACHE_MB\" ] && continue\n  SIZED+=(\"${size}\\t${p}\")\ndone\n```\n\nPlugins 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`\n\norders by size descending and picks from the top.\n\n**Layer 3: the weekly cap**\n\nA single apply run disables at most `WEEKLY_MAX=5`\n\nplugins.\n\n```\nSELECTED=()\nif [ \"${#SIZED[@]}\" -gt 0 ]; then\n  while IFS=$'\\t' read -r sz p; do\n    SELECTED+=(\"$p\")\n    [ \"${#SELECTED[@]}\" -ge \"$WEEKLY_MAX\" ] && break\n  done < <(printf '%b\\n' \"${SIZED[@]}\" | sort -rn)\nfi\n```\n\nDropping 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`\n\n.\n\nThe actual disable operation is delegated to a separate script, `plugin-disable.sh`\n\n. The point is to centralize `settings.json`\n\nrewrites in one place.\n\nThis script runs automatically via LaunchAgent **every Sunday at 06:45**.\n\n```\n<key>StartCalendarInterval</key>\n<dict>\n  <key>Hour</key>\n  <integer>6</integer>\n  <key>Minute</key>\n  <integer>45</integer>\n  <key>Weekday</key>\n  <integer>0</integer>\n</dict>\n```\n\n`Weekday`\n\n`0`\n\nis 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.\n\nLogs are written to two places: `~/.claude/logs/plugin-auto-disable.log`\n\n, and `com.shun.plugin-auto-disable.log`\n\nas specified in the plist's `StandardOutPath`\n\n/ `StandardErrorPath`\n\n(because the script does `tee -a \"$LOGFILE\"`\n\ninternally). Output looks like this.\n\n```\n[2026-07-13 06:45:01] auto-disable run (mode=apply, days=30)\n[2026-07-13 06:45:03] dormant candidates: 12\n[2026-07-13 06:45:03] selected for action (>=5MB, max 5): 5\n  [APPLY] expo (47MB cache)\n  [APPLY] sequential-thinking (23MB cache)\n  [APPLY] drawio-skill (18MB cache)\n  [APPLY] agent-eval (12MB cache)\n  [APPLY] benchmarks (9MB cache)\n[2026-07-13 06:45:07] applied disable for 5 plugin(s)\n```\n\n47MB + 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.\n\nEvery line of the jq pipeline quoted earlier has a purpose. Let me dig into the easy-to-miss parts in order.\n\nFirst, the trailing `?`\n\non `fromjson?`\n\n. 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`\n\n(JSON Lines) format, but there can be lines truncated mid-write — for example when the Mac goes to sleep during a write. With `fromjson`\n\n(no `?`\n\n), a single parse error halts the entire pipeline. With the `?`\n\n, it runs straight through hundreds of thousands of JSONL lines without complaint.\n\nNext, `select(.type==\"assistant\")`\n\n. Events in the JSONL are split by the `type`\n\nfield into `user`\n\n/ `assistant`\n\n/ `tool_result`\n\nand so on. The record of a tool being **called** exists only inside assistant messages. `tool_result`\n\ncontains the tool's response, but there are no `type==\"tool_use\"`\n\nblocks in it. Without this filter, the content expansion downstream gets confused.\n\n`.message.content[]?`\n\nexpands the array, and `select(.type==\"tool_use\")`\n\nextracts only the actual tool-call blocks. This is the decisive difference from the old implementation, and the cause of the failure described later.\n\nThe Skill tool side is handled like this.\n\n```\nif .name==\"Skill\"\nthen ((.input.skill // \"\") | select(contains(\":\")) | split(\":\")[0])\n```\n\n`select(contains(\":\"))`\n\nis 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`\n\n. Built-in skills and invalid values have no colon, so this excludes them. `// \"\"`\n\nis for null safety — if `input.skill`\n\ndoesn't exist it returns an empty string, which select then rejects.\n\nThe MCP tool side is as follows.\n\n```\n.name | select(startswith(\"mcp__plugin_\"))\n     | sub(\"^mcp__plugin_\";\"\") | split(\"_\")[0]\n```\n\nThis extracts `playwright`\n\nfrom a tool name like `mcp__plugin_playwright_playwright__browser_click`\n\n. `sub`\n\n(substitution) strips the prefix, then the remainder `playwright_playwright__browser_click`\n\nis 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.\n\nAfter aggregation, `comm -23`\n\nextracts the dormant (zero-usage) plugins as a difference.\n\n```\ncomm -23 <(echo \"$ENABLED_LIST\") <(echo \"$USED_LIST\")\n```\n\n`comm -23`\n\nis 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`\n\nat construction time, and USED_LIST is deduplicated with `sort -u`\n\nafter aggregation. Neglect this and `comm`\n\n's output breaks.\n\n`awk -F@ '{print $1}'`\n\nexists to extract just the name portion from plugins registered with a version, like `context7@1.0.0`\n\n. In Claude Code's settings.json, `enabledPlugins`\n\nkeys can take the `context7@1.0.0`\n\nform. Using `@`\n\nas the delimiter and taking only the first field lets it match correctly against `context7`\n\non the usage-log side.\n\n`plugin-auto-disable.sh`\n\nuses python3 to extract the list of enabled plugins.\n\n``` python\nENABLED=$(python3 -c \"import json; print('\\n'.join(\n  k.split('@')[0]\n  for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items()\n  if v))\")\n```\n\nThe same thing in jq would be `jq -r '.enabledPlugins // {} | to_entries[] | select(.value) | .key'`\n\n(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)`\n\nbehaves subtly differently on falsy values (0 or an empty string) depending on the jq version, so I went with python3's `if v`\n\nto drop falsy values explicitly. The judgment was that in a script that rewrites configuration, the risk of \"selecting the wrong target\" should be minimized.\n\nThe actual disable operation is delegated to `plugin-disable.sh`\n\n.\n\n```\n\"$HOME/.claude/scripts/plugin-disable.sh\" apply \"${CANDIDATES[@]}\"\n```\n\nConcentrating the `settings.json`\n\nrewrite 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.\n\nlaunchd **inherits none of your shell environment**. It reads neither `.zshrc`\n\nnor `.zprofile`\n\n. In other words, even if `jq`\n\n, `python3`\n\n, and `node`\n\nwork in your terminal, via launchd they run with only `/usr/bin:/bin`\n\non PATH. Homebrew's jq and nvm's node won't be found unless you tell it explicitly.\n\nThat's the role of `EnvironmentVariables`\n\n. Both plists contain the following.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:\n          /opt/homebrew/bin:/opt/homebrew/sbin:\n          /usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:\n          ~/.local/bin</string>\n</dict>\n```\n\nnvm's bin is placed first so that nvm always wins when versions get mixed with the system node. Homebrew `bin`\n\ncomes next.\n\nUsing `>`\n\nand `>>`\n\ndifferently for log output is also an important design choice. plugin-usage.plist's ProgramArguments looks like this.\n\n```\n<string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>\n```\n\n`>`\n\nis the XML escape for `>`\n\n. It **overwrites** (`>`\n\n) 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 `>>`\n\n. That's because a history of what was disabled and when is needed.\n\n`ProcessType: Background`\n\ntells 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.\n\nBoth plists also specify `StandardOutPath`\n\n/ `StandardErrorPath`\n\n, but the `2>&1`\n\nredirect 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.\n\nWhen I first wrote plugin-usage.sh, I didn't use jq — I ran grep across all the JSONL files.\n\n```\n# 旧実装（動かない）\ngrep -rh \"mcp__plugin_${plugin_name}\" ~/.claude/projects/ | wc -l\n```\n\nAfter 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.\n\nI figured out the cause when I looked directly at the JSONL contents. At the start of each session, Claude Code sends a `system-reminder`\n\nsaying \"deferred tools are now available.\" Inside it is a plain-text **list of every tool name** owned by the enabled plugins.\n\n```\nmcp__plugin_terraform_terraform__workspace_list\nmcp__plugin_terraform_terraform__resource_read\n…（以下続く）\n```\n\ngrep **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.\"\n\nWith 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.\n\nThe fix is to walk the JSON structure properly with jq. The three-stage filter `select(.type==\"assistant\")`\n\n→ `.message.content[]?`\n\n→ `select(.type==\"tool_use\")`\n\nextracts **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.\n\nAfter the fix, terraform landed in the Dormant list (of course it did), and the negative Dormant count disappeared.\n\n`status`\n\nvariable is read-only\nWhen running things through LaunchAgent, I had written logic to emit a notification based on the script's exit code.\n\n```\n# 旧実装（zshで動かない）\nplugin-disable.sh apply \"${CANDIDATES[@]}\"\nstatus=$?\nif [ \"$status\" -ne 0 ]; then\n  echo \"[ERROR] disable failed with code $status\" | tee -a \"$LOGFILE\"\nfi\n```\n\nRun 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`\n\n, the script kept going instead of stopping on the error.\n\nThe 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\n\n`status=$?`\n\nand zsh `[ \"$status\" -ne 0 ]`\n\nthen references the current value of `status`\n\n(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`\n\nunder zsh as-is. Because LaunchAgent's ProgramArguments used `/bin/zsh -c`\n\n, it went unnoticed in the terminal (fish/bash startup) and misbehaved only under launchd's zsh.\n\nThe fix is just renaming the variable.\n\n```\n# 修正後\nplugin-disable.sh apply \"${CANDIDATES[@]}\"\nrc=$?\nif [ \"$rc\" -ne 0 ]; then\n  echo \"[ERROR] disable failed with code $rc\" | tee -a \"$LOGFILE\"\nfi\n```\n\n`rc`\n\nisn't reserved in zsh. That brought the failure notification back. Generally speaking, zsh scripts have many system variables bash doesn't — `status`\n\n, `ARGC`\n\n, `argv`\n\n, `match`\n\n, and more. Keep just `status`\n\nin mind and you'll never hit this trap again.\n\nWhen I first wrote the plist for the LaunchAgent, I didn't include `EnvironmentVariables`\n\n. `jq --version`\n\nworks 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`\n\nwas blank, and `StandardOutPath`\n\nwas blank too. The script just went silent and ended.\n\nlaunchd has only `/usr/bin:/bin:/usr/sbin:/sbin`\n\non PATH. Even though Homebrew puts binaries in `/opt/homebrew/bin`\n\nand nvm in `~/.nvm/versions/node/v24.13.0/bin`\n\n, launchd can't see them.\n\nWhat made it worse: when `jq`\n\nisn't found, under `set -uo pipefail`\n\nthe 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.\n\nThe fix is writing an explicit PATH into the plist.\n\n```\n<key>EnvironmentVariables</key>\n<dict>\n  <key>PATH</key>\n  <string>~/.nvm/versions/node/v24.13.0/bin:/opt/homebrew/bin:…</string>\n</dict>\n```\n\nReloading with `launchctl unload`\n\n→ `launchctl load`\n\nwas 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.\n\n`wc -l`\n\nreturns spaces\nThis is the part of plugin-usage.sh that computes the Dormant count.\n\n```\nUSED_COUNT=$(wc -l < \"$TMP\" | tr -d ' ')\nDORMANT_COUNT=$(comm -23 … | wc -l | tr -d ' ')\n```\n\nThe trailing `tr -d ' '`\n\nwasn't there originally. macOS's `wc -l`\n\nreturns not `42`\n\nbut `42`\n\n(space-padded on the left). GNU coreutils on Linux has no such padding; the BSD-derived macOS one does.\n\nPut 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**`\n\n. 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 ]`\n\nstopped evaluating correctly. The string `\" 12\"`\n\nis the same as `\"12\"`\n\nin an arithmetic comparison, but behavior varies subtly by environment.\n\nInserting `tr -d ' '`\n\nsolves 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.\n\nAll 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`\n\nplus the plist's `StandardErrorPath`\n\n) is also a countermeasure against this \"fail silently\" pattern.\n\nBeyond the four failures covered above (grep ghosts, the `status`\n\nvariable, launchd PATH, and `wc -l`\n\npadding), there were other points where the actual wiring tripped me up. Each one has real code behind it.\n\n**Without printf '%b\\n', the tab stays a literal string.** On lines 85–90 of\n\n`plugin-auto-disable.sh`\n\n, the cache size and plugin name are pushed into an array as `SIZED+=(\"${size}\\t${p}\")`\n\n, but in a single-quoted context that `\\t`\n\nis two characters, backslash and t. Only by using the `%b`\n\nformat in `printf '%b\\n' \"${SIZED[@]}\" | sort -rn`\n\non line 96 does it expand into a tab. Use `echo`\n\nor `printf '%s\\n'`\n\nand `sort -rn`\n\nfails to recognize the fields correctly, breaking the descending size sort.`comm -23`\n\nbreaks silently unless both lists are sorted.`comm`\n\nassumes its inputs are sorted in lexicographic order. Hand it unsorted input and `comm`\n\nraises no error — the output is simply undefined. That's why the design shapes `ENABLED_LIST`\n\nwith `sort -u`\n\nand runs `USED_LIST`\n\nthrough `sort -u`\n\nas well. Test with `sort`\n\nomitted and plugins that should appear in Dormant don't, or used plugins get mixed into Dormant.\n\n**The -u flag of set -uo pipefail kills the script on unbound variables.**\n\n`plugin-auto-disable.sh`\n\nstarts with `set -uo pipefail`\n\n. `-u`\n\nturns references to uninitialized variables into errors. Referencing `${#SIZED[@]}`\n\nwhile the `SIZED`\n\nor `SELECTED`\n\narrays are still empty is fine, but forget an initial value when adding a variable later and it exits immediately with `unbound variable`\n\n. 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}\"`\n\n).**Without trap 'rm -f ...' EXIT, tmp files linger.** Line 59 of\n\n`plugin-auto-disable.sh`\n\nhas `trap 'rm -f \"$TMP\" \"$SKILL_TMP\"' EXIT`\n\n. Under `set -uo pipefail`\n\n, when the script terminates midway, tmp files stay behind in `/tmp/`\n\n. LaunchAgent runs the same script weekly, so they pile up and gradually pollute `/tmp`\n\n. `trap`\n\nis mandatory as a line of defense.**Forget the plist's XML escaping and launchctl refuses to load it.** Line 18 of `plugin-usage.plist`\n\nis this.\n\n```\n  <string>/bin/zsh -c '…/plugin-usage.sh 14 &gt; …/plugin-audit-latest.md 2&gt;&amp;1'</string>\n```\n\nUnless `>`\n\nis escaped as `>`\n\nand `&`\n\nas `&`\n\n, `launchctl load`\n\nreturns a `Format error`\n\nat the point of parsing the plist as XML and silently does nothing. Accidentally introducing a raw `>`\n\nwhile editing is not an unusual mistake. Getting into the habit of verifying beforehand with `plutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist`\n\nprevents it.\n\n**Not being aware that Weekday=0 is Sunday means it runs on Monday.** launchd's\n\n`Weekday`\n\nkey is zero-based: 0=Sunday, 1=Monday. If you want the weekly disable to \"run Monday morning,\" setting `Weekday=1`\n\nis correct — but misread it as `0=Monday`\n\nand set `Weekday=0`\n\n, 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`\n\n(Sunday) / `Hour=6`\n\n/ `Minute=45`\n\nsetting in `com.shun.plugin-auto-disable.plist`\n\nis 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\n\n`caffeinate -s`\n\n, 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\n\n`plugin-auto-disable.sh`\n\ncalls `\"$HOME/.claude/scripts/plugin-disable.sh\" apply \"${CANDIDATES[@]}\"`\n\n. If that downstream script doesn't exist or isn't executable, `set -uo pipefail`\n\nends the entire script right there, and the subsequent `echo \"[$(ts)] applied disable\"`\n\nlog 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`\n\nand `chmod +x`\n\ncomes first.**Expansion accidents when embedding shell variables into python3's -c inline script.** Line 66's\n\n`python3 -c \"import json; print('\\n'.join(k.split('@')[0] for k,v in json.load(open('$SETTINGS'))['enabledPlugins'].items() if v))\"`\n\nhas `$SETTINGS`\n\nexpanded by the shell inside double quotes. If the path contains a space, python3 raises a parse error. The real path of `~/.claude/settings.json`\n\nhas 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`\n\nseparately or pass it via a heredoc.**Without restarting Claude Code after disabling, the deferred tools section isn't updated.** Rewriting `settings.json`\n\ntakes 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.\n\nReproducible principles distilled from actually running this.\n\n**1. Make MODE=dry the default and always confirm with a dry-run before apply**\n\nArgument handling on line 1 of `plugin-auto-disable.sh`\n\nis `MODE=\"${1:-dry}\"`\n\n. 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.\n\n**2. Write the reason in a comment on the Protected list**\n\nThe PROTECTED list on lines 26–37 of `plugin-auto-disable.sh`\n\nhas 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.\n\n**3. Prevent over-disabling with the two-stage filter WEEKLY_MAX=5 and MIN_CACHE_MB=5**\n\nDropping 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`\n\n.\n\n**4. Write the results of which <command> straight into the launchd PATH**\n\nFor `jq`\n\n, `python3`\n\n, and `node`\n\nalike, 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`\n\nand `which python3`\n\nfor the commands you use. If you use nvm, that's `/Users/<username>/.nvm/versions/node/vX.Y.Z/bin`\n\n; for Homebrew, put `/opt/homebrew/bin`\n\nnear the front. The actual setting in `com.shun.plugin-auto-disable.plist`\n\nis, in order: nvm v24.13.0 bin → opt/homebrew/bin → homebrew/sbin → usr/local/bin → usr/bin → bin → sbin → .local/bin.\n\n**5. Give logs a double structure: script-internal tee -a plus the plist's StandardErrorPath**\n\nThe main log output of `plugin-auto-disable.sh`\n\nis appended to `~/.claude/logs/plugin-auto-disable.log`\n\nvia `| tee -a \"$LOGFILE\"`\n\n. The plist's `StandardErrorPath`\n\npoints 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.\n\n**6. Always include set -uo pipefail to kill silent failures**\n\nUnlike the terminal, scripts run via LaunchAgent show the user nothing when they fail. Without `set -uo pipefail`\n\n, even if `jq`\n\nisn't found, everything downstream continues and it looks like \"nothing happened.\" Adding `-uo pipefail`\n\nmakes both mid-pipe failures and non-zero exit codes terminate the script immediately. The error gets written to the log file specified in `StandardErrorPath`\n\n, so the cause of failure is preserved.\n\n**7. Always validate the plist with plutil -lint before loading**\n\n```\nplutil -lint ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist\n```\n\nIf there's no problem, it returns `com.shun.plugin-auto-disable.plist: OK`\n\n. Missing XML escapes and forgotten dict/array closing tags get caught here. `launchctl load`\n\ncan fail silently on parse errors, so lint first, then load.\n\n**8. Strictly do launchctl unload → launchctl load to apply plist changes**\n\nEdit 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`\n\n→ `launchctl load ~/Library/LaunchAgents/com.shun.plugin-auto-disable.plist`\n\n. Some articles recommend `launchctl bootout`\n\n/ `launchctl bootstrap`\n\non macOS Ventura and later, but for user-scope LaunchAgents `unload`\n\n/`load`\n\ncontinues to work.\n\n**9. Run plugin-usage.sh manually once and check the aggregation before leaving it to LaunchAgent**\n\n```\n~/.claude/scripts/plugin-usage.sh 14\n```\n\nRun 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.\n\n**10. Always chain tr -d ' ' after wc -l**\n\nmacOS's `wc -l`\n\nputs spaces to the left of the number. In an environment where `wc -l < \"$TMP\"`\n\nreturns `12`\n\n, 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 ' '`\n\nprevents later bugs. It's the classic landmine when bringing a script written for GNU coreutils onto macOS.\n\n**11. Make is_protected() judge by exact match, not partial match**\n\nThe `is_protected()`\n\nfunction in `plugin-auto-disable.sh`\n\nuses the exact comparison `[ \"$p\" = \"$x\" ]`\n\n. That's to distinguish `code-review`\n\nfrom `code-reviewer`\n\n. Make it a partial match with something like `grep -q \"$x\"`\n\n, and when `code-review`\n\nis Protected, `code-reviewer`\n\nbecomes protected too. Plugin names are often similar, so exact match is the safe choice.\n\n**12. Deliberately offset the windows for the daily audit (14 days) and the weekly disable (30 days)**\n\n`plugin-usage.sh`\n\n's default `DAYS=14`\n\nand `plugin-auto-disable.sh`\n\n's `DAYS=30`\n\nare 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.\n\n**13. After disabling, verify the deferred tools line count for those plugins in the next session**\n\nCheck 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.\n\nAutomatic 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.\"\n\n`plugin-usage.sh`\n\ncounts only actual tool_use events with jq; `plugin-auto-disable.sh`\n\npasses 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.\n\nCounting ghosts with grep. Stepping on zsh's `status`\n\nvariable. Forgetting to give launchd a PATH. macOS's `wc -l`\n\nreturning 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.\n\nA ¥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.\n\nI'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.\n\n📕 [Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート](https://note.com/bokuwalily/n/n849b3a07784a)\n\n*Written by **Lily** — I ship iOS apps and automate my content stack with Claude Code.\n\nFollow along: [Portfolio](https://bokuwalily.com) · [X](https://x.com/bokuwalily) · [GitHub](https://github.com/bokuwalily)*", "url": "https://wpnews.pro/news/the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30", "canonical_source": "https://dev.to/bokuwalily/the-4000-token-tax-auto-disabling-claude-code-plugins-you-havent-touched-in-30-days-ac5", "published_at": "2026-08-22 11:00:06+00:00", "updated_at": "2026-08-22 11:14:30.700743+00:00", "lang": "en", "topics": ["developer-tools", "ai-tools", "ai-agents", "mlops"], "entities": ["Claude Code", "context7", "playwright", "expo", "chrome-devtools", "sequential-thinking", "LaunchAgent"], "alternates": {"html": "https://wpnews.pro/news/the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30", "markdown": "https://wpnews.pro/news/the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30.md", "text": "https://wpnews.pro/news/the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30.txt", "jsonld": "https://wpnews.pro/news/the-4000-token-tax-auto-disabling-claude-code-plugins-you-haven-t-touched-in-30.jsonld"}}