Skip to content

Writing a bleach plugin ​

A plugin is any executable that speaks JSON on stdio. No Swift, no rebuild, no linking — a 40-line script is a complete plugin.

Plugins exist because the valuable knowledge in a tool like this is exactly the part that can't be generalised: which directory belongs to what, and which parts of it are dead. That knowledge should be contributable without touching the machinery that decides what is safe to remove.

What a plugin can and cannot do ​

A plugin proposes. It never acts.

  • It cannot delete, move, or write anything.
  • It never receives file contents — only paths, names, and sizes.
  • It can only speak about paths inside the scope it declared in its manifest. Anything else it returns is discarded and reported as a warning.
  • It cannot weaken a verdict. A hint can only sharpen UNKNOWN, which means "core reached no conclusion", or make a verdict more protective. If core tiered a path PROTECTED, REVIEW, or CACHE-SAFE, that wins and the plugin's disagreement is recorded in the evidence trail instead.
  • Its evidence weights are clamped to ±10, so it cannot swamp core scoring.

Every path a plugin returns is re-validated before bleach will even measure it:

  1. absolute, and existing on disk
  2. inside the user's home directory
  3. inside one of the plugin's declared owns prefixes
  4. unchanged by path standardisation (blocks .. traversal)
  5. not a symlink
  6. still inside the home and the declared scope once every symlink in the path is resolved (blocks a symlinked parent directory)

And then, because plugin output only ever becomes a plan, bleach apply re-validates all of it again against live state.

Discovery ​

Plugins are executed, so where bleach looks for them is a trust decision. There are exactly two locations, both chosen deliberately by you:

  1. ~/.config/bleach/plugins/
  2. every directory in $BLEACH_PLUGIN_PATH (colon-separated)

A plugin is either an executable file, or a directory containing an executable named plugin. Check what was found with:

sh
bleach rules --plugins

Pass --no-plugins to any scanning command to skip discovery entirely.

Not the working directory

Earlier versions also searched ./plugins/ relative to the working directory, which meant running bleach scan inside a repository that happened to ship a plugins/ directory executed its contents. If you are developing a plugin from a checkout, opt in explicitly:

sh
BLEACH_PLUGIN_PATH=./plugins bleach scan

Protocol ​

Version 1. Three modes, selected by argv[1].

manifest ​

Called with no stdin. Print a manifest and exit 0.

json
{
  "protocol": 1,
  "name": "my-tool-sessions",
  "description": "Stale my-tool session state",
  "capabilities": ["enumerate", "resolve"],
  "owns": ["~/.my-tool", "~/Library/Application Support/com.example.mytool"]
}

owns is a hard boundary, not a hint. Declare the narrowest scope that works.

enumerate ​

Emit candidates inside your scope — this is how you split a directory bleach would otherwise treat as one opaque blob.

Request on stdin:

json
{ "protocol": 1, "home": "/Users/you", "stale_days": 180,
  "scope": ["/Users/you/.my-tool"] }

Response on stdout:

json
{ "protocol": 1,
  "candidates": [
    { "path": "/Users/you/.my-tool/sessions/abc",
      "label": "my-tool session: /Users/you/code/deleted-project",
      "kind": "state",
      "tier_hint": "orphan_likely",
      "evidence": [
        { "kind": "noOwnerFound",
          "detail": "the project this session belongs to no longer exists",
          "weight": -8 }
      ] } ] }

Do not report sizes. bleach measures every candidate itself.

resolve ​

Contribute evidence for candidates bleach already found. You are only shown candidates inside your own scope — never a listing of the whole Library.

Request:

json
{ "protocol": 1, "home": "/Users/you", "stale_days": 180,
  "candidates": [
    { "path": "/Users/you/.my-tool", "name": ".my-tool",
      "root_id": "dotdirs", "size_bytes": 12345678 } ] }

Response:

json
{ "protocol": 1,
  "resolutions": [
    { "path": "/Users/you/.my-tool",
      "owner_name": "My Tool",
      "owner_bundle_id": "com.example.mytool",
      "tier_hint": "review",
      "evidence": [
        { "kind": "cacheRule", "detail": "rebuilt on next run", "weight": -2 } ] } ] }

Fields ​

kind (candidate): cache or state. cache means regenerable.

tier_hint: cache_safe, orphan_likely, review, protected, or unknown. An unrecognised value becomes unknown — report-only — rather than anything actionable.

evidence[].kind: any of the core evidence kinds; the useful ones are noOwnerFound, ownerMissingFromDisk, stale, recentActivity, cacheRule, userDataMarker, versionSibling, nameMatch, runningProcess. Unknown kinds degrade to a generic bucket instead of failing.

evidence[].weight: sign convention — positive argues the path is in use (keep), negative argues it's abandoned (clean). Clamped to ±10.

evidence[].detail: shown verbatim to the user, prefixed with your plugin name. This is the whole point. Write the sentence you'd want to read before deleting a gigabyte.

Guidance ​

Find a signal mtime can't give you. The claude-code-sessions plugin decodes session directory names back into project paths and checks whether the project still exists. "The project this belongs to was deleted" is categorical; "untouched for 200 days" is circumstantial. Look for the categorical one.

Never propose the live database. opencode-sessions deliberately omits opencode.db from its candidate list entirely. Leaving something out is cheaper than protecting it.

Prefer review when unsure. It's reported to the user but non-actionable without an explicit opt-in. A plugin that lands everything in orphan_likely will get uninstalled the first time it's wrong.

Split by role, not by size. A 2.8 GB directory reported as one line is useless. The same directory split into "redownloadable tooling", "per-session snapshots", and "live database" is immediately actionable.

Skeleton ​

python
#!/usr/bin/env python3
import json, os, sys

MANIFEST = {
    "protocol": 1,
    "name": "my-tool-sessions",
    "description": "Stale my-tool state",
    "capabilities": ["enumerate"],
    "owns": ["~/.my-tool"],
}

def enumerate_candidates(request):
    root = os.path.expanduser("~/.my-tool")
    out = []
    if os.path.isdir(root):
        for entry in sorted(os.listdir(root)):
            path = os.path.join(root, entry)
            if not os.path.isdir(path) or os.path.islink(path):
                continue
            out.append({
                "path": path,
                "label": "my-tool %s" % entry,
                "kind": "state",
                "tier_hint": "review",
                "evidence": [{"kind": "cacheRule",
                              "detail": "regenerated on next run",
                              "weight": -2}],
            })
    return {"protocol": 1, "candidates": out}

mode = sys.argv[1] if len(sys.argv) > 1 else "manifest"
if mode == "manifest":
    json.dump(MANIFEST, sys.stdout)
elif mode == "enumerate":
    json.dump(enumerate_candidates(json.load(sys.stdin)), sys.stdout)
else:
    sys.exit(2)

Drop it in ~/.config/bleach/plugins/, chmod +x, and confirm with bleach rules --plugins.

When the answer is "don't propose anything" ​

The bundled apple-container plugin is worth reading before you write one, because it proposes no paths at all and that is the point of it.

container keeps ~50 GB under ~/Library/Application Support/com.apple.container, which the com.apple. protected prefix reduced to one opaque PROTECTED row. But its storage is content-addressable: snapshots/ is keyed by digest and shared between images and layers, content/blobs is an OCI content store, and the runtime holds its own index of what references what. Moving any single directory aside would leave container pointing at digests that no longer exist — which is the exact failure bleach exists to avoid, just committed by a plugin instead of by the core.

So it declares resolve rather than enumerate, and spends its turn asking the runtime questions instead: container system df --format json reports exactly how many bytes are reclaimable, and container ls -a --format json says which containers are actually running rather than guessing from mtimes. The result is a REVIEW row carrying "44.3 GB of your 44.7 GB of images is unused, run container image prune".

Two details in it generalise:

  • It never reports owner_bundle_id. Returning com.apple.container would match the protected com.apple. prefix and hard-protect the row, hiding the very advice the plugin exists to give.
  • It never claims the runningProcess evidence kind for a running container. That kind means "a host process is executing from this path" and is a hard protection in the classifier, so borrowing it would both misdescribe a guest VM and suppress the plugin's own tier hint.

Volumes are the counter-example. container volume prune removes volumes with no container reference, which a database volume for a project you are not running right now does not have — while still being the only copy of that data. bleach protects that directory outright and the plugin names the volumes rather than recommending the command.

Debugging ​

Plugins are ordinary programs, so run them by hand:

sh
./plugins/my-plugin manifest | jq
echo '{"protocol":1,"home":"'$HOME'","stale_days":180,"scope":["'$HOME'/.my-tool"]}' \
  | ./plugins/my-plugin enumerate | jq

Failures never abort a scan. A plugin that crashes, times out (20s), or emits bad JSON is skipped and reported by bleach rules --plugins.