diffstat: the first thing to reach for when reviewing a big PR

When you're staring at a 5000-line diff, your eyes glaze over before you've read past the first file. diffstat gives you the bird's-eye view in one screen:

gh pr diff 137 | diffstat
 lib/relay-runtime.sh      | 2692 -----------------------------------
 lib/relay/backends.sh     |  142 ++++
 lib/relay/cli.sh          |  326 +++++++
 lib/relay/cycle.sh        |   99 ++
 lib/relay/issue-comments..|  179 ++++++
 lib/relay/issues.sh       |  173 ++++++
 lib/relay/project-regist..|  274 ++++++++
 lib/relay/session.sh      |  692 ++++++++++++++++++++++++++
 lib/relay/telegram.sh     |  389 ++++++++++++++++
 lib/relay/tmux-send.sh    |  337 +++++++++++++++
 lib/relay/worktree.sh     |  206 ++++++++
 11 files changed, 2848 insertions(+), 2686 deletions(-)

From that one output you can immediately see: this is a file split (one huge file → ten smaller ones), the net change is roughly neutral, and session.sh is the biggest new file at 692 lines. That's enough to form a review strategy before reading a single line of diff.

It also works with local diffs:

git diff main..my-branch | diffstat

Install on Debian/Ubuntu:

sudo apt install diffstat

When your "extensible" feature never works: trace the root, not just the hook

The my-ai-team relay has a .my-ai-team/<mode>.md prompt override mechanism — drop a markdown file into a directory and it replaces the shipped system prompt for that agent mode. It never worked in practice. The code was there, the tests passed, but real users' override files were never found.

The bug was a root resolution mismatch, not a logic error.

The mechanism

_mux_prompt_override() in lib/relay-runtime.sh decides which prompt file an agent gets:

relay_root="$(_mux_relay_root)"
override_path="${relay_root}/.my-ai-team/${mode}.md"

It looks for .my-ai-team/<mode>.md under the relay root, then falls back to agents/<mode>.md.

What went wrong

_mux_relay_root() returns the install root (~/.local/share/my-ai-team/), not the project root. Meanwhile mux-agent-launch correctly receives MUX_PROJECT_ROOT (the user's project directory) and even cds into it — but _mux_prompt_override() never sees it.

Users put .my-ai-team/ in their project. The code only looks in the install dir. Ship mismatch.

Two problems stacked

  1. Project root never checked — the override path only searches the install root
  2. Install wipes install-root overridesinstall.sh does a full directory swap on every run (prepare_install_root_swap renames the old install root, moves staging in, deletes backup). A .my-ai-team/ manually placed at the install root is destroyed on reinstall

The fix: move global overrides out of managed territory

Instead of trying to preserve user data inside the install root (which install.sh owns and swaps), put global overrides somewhere install.sh never touches:

Resolution order:
1. ${MUX_PROJECT_ROOT}/.my-ai-team/<mode>.md   — project-level (highest priority)
2. ~/.config/my-ai-team/<mode>.md               — user-global (XDG config)
3. ${relay_root}/agents/<mode>.md               — shipped default

No install.sh changes needed. The relay root stays a clean install-managed directory. User data lives in ~/.config/, which is user-owned by convention.

Why tests didn't catch it

The existing tests in test/prompt-override_test.sh test _mux_prompt_override() in isolation — they set up a fake relay root with .my-ai-team/ under it and verify the function finds it. The tests pass because the function's internal logic is correct. What's wrong is the root that gets passed in at runtime: the function is called with the install root when it should also check the project root. Tests that mock only one root won't catch a missing root.

"Permission denied" renaming a folder on Windows is usually an open handle, not a permission

You try to rename a directory and get blocked:

# Git Bash
$ mv my-ai-team my-ai-team.old.1
mv: cannot move 'my-ai-team' to 'my-ai-team.old.1': Permission denied
# PowerShell, even elevated
PS> Move-Item .\my-ai-team .\my-ai-team.old.1
Move-Item: You do not have sufficient access rights to perform this operation
or the item is hidden, system, or read only.

The instinct is to reach for chown/takeown/icacls. On Windows that's usually the wrong tree to bark up. When even an administrator shell is denied, the cause is almost never the ACL — it's a runtime lock: some process has a file inside the directory open, or has the directory itself as its current working directory. Windows surfaces that lock as "Access denied," which reads like a permissions problem but isn't.

Rule out permissions in two commands

…more

Turn off Tailscale SSH without re-logging in

If you enabled Tailscale's built-in SSH server with tailscale up --ssh and then realized you'd rather just use OpenSSH with a real key (no periodic browser auth to Tailscale, fewer moving parts), the way you turn it off matters.

Use this:

sudo tailscale set --ssh=false

It flips that one flag and nothing else. tailscale status stops listing the SSH service, OpenSSH is untouched, your existing tailnet connection keeps running.

The trap is reaching for tailscale up --ssh=false. up is sticky — it reapplies your full login configuration — so it can prod you to re-authenticate in the browser, and any other flags you tweaked but forgot about get reset to defaults too. set is the surgical knob for toggling a single option without disturbing the rest.

Same shape applies to other booleans Tailscale exposes (--advertise-exit-node, --accept-routes, --shields-up): once the node is up and authenticated, prefer tailscale set --flag=... over re-running tailscale up.

Designing "Explore" Mode: Define by Entry, Not by Fences

I run a small multi-agent dev setup (my-ai-team) with three session modes: team (planner/developer/reviewer relay), adhoc (one agent owns the whole delivery cycle), and explore. The explore mode is still an RFC — we haven't shipped it yet. But the design already taught us something useful about how to scope agent autonomy.

The first draft was all negatives

The obvious way to define an investigation-only mode is to list what it can't do: no issues, no branches, no PRs, report findings and stop. That works — until it doesn't.

The problem: there's always a case the fence didn't anticipate. If poking at a bug turns up a one-line fix, the rules force an awkward choice between "break protocol and just fix it" or "hand off to a separate session for a trivial change." Neither is right.

Define by entry condition instead

The fix was to redefine explore by how it starts, not what it's forbidden from doing. Team and adhoc carry a delivery obligation from the first message — they begin with a predefined task. Explore enters from an open topic with an uncertain goal. That's the only difference.

That single shift dissolves the fence. The mode now has several legitimate exits — adjourn with findings, park a ticket for later, or hand work off to delivery — and none of them needs a special-case rule, because none contradicts the definition.

The gate is the ticket, not the PR

The hard part of any "investigation that might turn into work" mode is the autonomy boundary: when is the agent allowed to commit to real consequences?

The answer turned out to be a primitive we already had. Opening a ticket is a serious act. A ticket without a blocked label is a clear signal that delivery may run all the way to merge. A ticket with blocked means "real, but the timing isn't ripe." So the gate isn't a new checkpoint bolted on before the PR — it's the ticket's own blocked/non-blocked status. No new machinery, and the decision lives where the seriousness already lives.

The ticket as a membrane

We also cut the scope so explore never writes delivery code. Its output ceiling is a (possibly newly created) repo plus one or more detailed tickets. Implementation always belongs to adhoc or team.

The ticket becomes a membrane: explore writes it, delivery consumes it, and no single prompt is asked to be both an investigation prompt and a delivery prompt. The detail written into the ticket carries the discussion's context across the handoff.

A nice side effect: no repo → no ticket → no PR. The repo is a precondition for a ticket, the ticket is the delivery gate, so a discussion that never produces a repo simply can't produce delivery. That's a valid ending, not an error to guard against.

Two smaller things worth keeping

Gate autonomy on reversibility and blast radius, not on who's human. The instinct was "two agents shouldn't land work without a person in the loop." But the honest axis is actor-neutral: an irreversible, wide-blast-radius action deserves a second check regardless of whether a human or an agent is driving it. Publishing a ticket, by contrast, is reversible and low-blast — it needs no special gate.

We designed explore by spending an hour inside it. The session had actually booted under the adhoc prompt, but because we kept the conversation open-ended and goal-uncertain, it behaved exactly like the mode we were inventing. It ended, fittingly, by publishing a single ticket and nothing more.