Welcome to my personal site. In addition to the posts you see below, feel free to explore other places where I stash content here. The now page is an occasionally-updated page about long-term plans and activities. I try to post several times a week and those updates appear on the journal page. Sometimes I post tidbits about things I learn in the til page. More info in: toolkit, readings, to learn, gallery, colophon, and changelog.


As a dual Canadian/U.S. citizen 🇨🇦 🇺🇸, I am opposed to the deeply corrupt and incompetent Administration of the U.S. government and urge Americans to vote all Republicans out of office.

Getting Codex to clean up after itself

Terminal listing Codex app-server-daemon release directories

A pet peeve of mine is software that doesn’t clean up after itself. If you are a Codex user, check out your ~/.codex/packages/app-server-daemon/releases I’m a relatively recent codex CLI user and already 12 releases of the app-server-daemon left there. Why? Laziness probably. If I left these files in place and they accumulated at this rate, we would have 219 of them one year in. They are around 330 MB apiece, so we are talking about 72 GB of wasted space.

Hazel to the rescue

On macOS when I have an automated filesystem cleanup or organizational task, I turn to Hazel. Just point Hazel to the directory you want to watch, give it some criteria and actions to perform on matching files and/or directories. My goal here is to keep only the two most recent releases. Honestly we could keep only the most recent release, but I’m being conservative. To clean up the releases, first add a folder to Hazel pointed to ~/.codex/packages/app-server-daemon/releases then a new rule “Clean previous .codex app server daemon releases”.

Add two (“All”) critera:

  1. Kind is Folder
  2. Passes shell script

For the script:

#!/bin/zsh

# Directory being evaluated
candidate="$1"
name="${candidate:t}"
parent="${candidate:h}"
platform='aarch64-apple-darwin'

# Check release-directory names without repeating a long expression.
is_release() {
    [[ $1 =~ ^[0-9]+\.[0-9]+\.[0-9]+-$platform$ ]]
}

is_release "$name" || exit 1

# Collect all valid release directories
versions=()

for path in "$parent"/*-aarch64-apple-darwin(N/); do
    version="${path:t}"

    if is_release "$version"; then
        versions+=("$version")
    fi
done

# Never delete anything if two or fewer versions exist
(( ${#versions} > 2 )) || exit 1

# Sort by numeric version components (ascending)
sorted=("${(@f)$(printf '%s\n' "${versions[@]}" |
    /usr/bin/sort -t. -k1,1n -k2,2n -k3,3n)}")

# Match only if this directory is NOT one of the newest two
[[ "$name" != "$sorted[-1]" && "$name" != "$sorted[-2]" ]]

Under the hood with the criterion script

Hazel supplies the full path of the folder it is evaluating as $1. The script saves that path in candidate, then uses zsh’s :t and :h modifiers to obtain its last path component (the release directory name) and its parent directory. Quoting those variables matters: it keeps the script correct even if a path contains spaces.1

The is_release helper is the guard rail. It refuses to match a candidate unless its name has the expected major.minor.patch-aarch64-apple-darwin form. The script calls it again while building the list, so an unrelated directory in releases cannot be selected for deletion. This version is deliberately specific to Apple Silicon. If Codex changes its release naming convention, or if you use an Intel Mac, adjust the platform value before enabling the rule.

(N/) is a zsh glob qualifier. The / limits the glob to directories, while N makes an empty match expand to nothing instead of producing an error. The versions array therefore contains only release directories that passed the name check.

The script exits with a non-zero status until there are at least three valid versions. Hazel treats that as a failed condition, so its Trash action cannot run. When there are three or more, sort compares the three numeric components of each version number in order. In zsh, sorted[-1] and sorted[-2] mean the last and second-to-last array items. The final test succeeds only for a directory that is neither of those two newest entries, which makes that directory eligible for the rule’s action.

We only match diretories that do not have the two most recent version numbers.

For the rule action, it’s simple Move to folder → Trash.

This is a modest workaround for a modest annoyance, but it is the sort of housekeeping that should not require attention every few weeks. Hazel keeps the release directory bounded, and moving old releases to the Trash leaves a recovery path if an update turns out to need an older copy.

I need to look for more leftover garbage. Where there’s one example of sloppiness, there’s probably more.

If you know of a better way to manage Codex’s retained releases, or have questions about the Hazel rule, please get in touch through my contact page.


  1. These are Zsh-specific expansion modifiers that won’t work in Bash; that’s why we have to specify /bin/zsh as the interpreter for the criterion shell script. ↩︎

Syncing Hazel rules between macOS computers

Hazel is a filesystem utility application that performs housekeeping activities on macOS directories. When it detects a change in a directory, it executes actions on files there based on matching criteria. That’s the good news. The bad news is that “sync,” in the way Hazel uses the term, means that all Macs share all the rules for a given shared directory. Individual rules cannot be excluded. For a given directory, the same rules run on all Macs that share it. Working around this takes some finagling. But if you’re a Hazel user, you’re probably already the sort that doesn’t mind some hacking. The workaround involves a few steps:

We’ll take each of these in turn.

Establish a common merged set of rules

This is the trickiest part of the process. The goal is to first create two sync files, one for Mac A and one for Mac B. Create these on a shared drive. I used my iCloud Drive, but other synced shares are fine. See Hazel Help for details on setting up rule sync.

Hazel Help page explaining rule sync limitations and the steps to set up a sync file

Initially, I had hoped that .hazelrules files were XML or JSON or the like, because that would facilitate merging rules between computers. But no such luck; it’s more complicated than that. Using xxd, I examined the .hazelrules file structure and found:

Hexadecimal view of a Hazel .hazelrules file showing its HZLR header and binary property list data

So, a .hazelrules file has two layers:

  1. A 40-byte Hazel header: HZLR (4 bytes), a 4-byte field whose value is 27 in these files, and a 32-byte SHA-256 checksum of the remaining bytes. I haven’t established what the 27 field means. Maybe it’s a version; I don’t know.
  2. A binary property list: This is an Apple NSKeyedArchiver archive. Its $top entry points to a HazelRuleSet, which points to an ordered list of HazelRule objects.

Each rule stores its name, identifier, modification date, conditions, actions, and options. Conditions are archived predicate objects; actions include objects such as “move to Trash” or “run shell script.” The archive uses numbered object references (UIDs), so a rule is a graph of linked objects rather than one self-contained block.

This is the point where, if I were more industrious, I would write an application to extract the original object graph and do some sort of intelligent merging. Maybe I will dive into that someday. But for now, I just allowed codex to examine the files and merge them. That way, it could properly repackage the merged files and compute the new checksum. With a little guidance from me—“For rule x, use the version from Computer A,” etc.—codex did a great job of merging.

Codex session prompted to merge two Hazel rule sets into a Desktop.hazelrules file

Once the files are satisfactorily merged, point the sync location for the affected directories on both Computer A and Computer B to the new merged .hazelrules file.

Using the computer name as a criterion

One of the problems with syncing rules between computers is that a given rule may apply to only one of them. Because Hazel syncing is a blunt instrument, there is no way to exclude rules from sync. It’s all or nothing. To circumvent that issue, you can create a criterion for the computer name. For example, if a rule should run only on my laptop, whose hostname is WokeAF.local, I can use hostname -s to act as a gate for the rule. To do this, add a “Passes shell script” criterion to an existing rule that you want to run only on that computer.

Hazel rule preview with a Passes shell script condition that checks the computer hostname

In the “Passes shell script” condition, when the script exits with 0, the criterion succeeds for this file. When the script exits with 1, the criterion fails. Succinctly, it’s:

# Use whatever computer name you want to run the rule
[[ "$(hostname -s | tr '[:upper:]' '[:lower:]')" == "wokeaf" ]]

Edit rule actions per computer

Some rule actions will also need to be modified to provide alternatives per computer. In this case, you must use the “Run shell script” action and provide conditional logic there. For example, my laptop and desktop are named “WokeAF” and “Vyger,” respectively, so I could set up the shell script logic in this way:

COMPUTER="$(hostname -s | tr '[:upper:]' '[:lower:]')"

case "$COMPUTER" in
    WokeAF)
        echo "Running WokeAF-specific actions"
        # commands for WokeAF go here
        ;;

    Vyger)
        echo "Running Vyger-specific actions"
        # commands for Vyger go here
        ;;

    *)
        echo "Unknown computer: $COMPUTER" >&2
        exit 1
        ;;
esac

exit 0

Alternatively, if you prefer not to use “Run shell script” actions and just want to use the built-in actions, you could create duplicate rules with different hostname criteria and different actions.


I wish this were a lot easier. And I wish that I did not have to use an LLM agent to complete the merger. (Though I’m happy it worked so well!) It’s entirely possible there may be an easier way to go about this. If you know of a better method, or if you just have questions or comments, please use my contact page.