Pinger, ponger, and the tab that could not say its name (Part 2 of 2)

A Claude Code teammate and a Codex CLI session played five rounds of ping-pong across two cmux panes yesterday. Fifty seconds, ten messages, in order, exactly once. Both sides in the traffic log under their own names; both tabs titled correctly, pinger and ponger, the whole run.

cmux with three panes: Claude Teams lead on the left, ponger (Codex CLI) top right, pinger (Claude Code teammate) bottom right, after five rounds
Left: team lead. Top right: ponger, Codex CLI. Bottom right: pinger, a Claude Code teammate, footer @pinger. Tab titles set by each agent naming itself.

That last clause is the news. The previous post was about what agents said to each other. This one is about the plumbing that lets a human watch them say it, and the four things that cost me time so they need not cost you any.

The cross-runtime run

Setup: cmux 0.64.22, Claude Code 2.1.248, Codex CLI 0.148.0. The lead is a Claude session started with cmux claude-teams. pinger is a Claude teammate, spawned with Agent(name: "pinger"), which on this build opens its own pane. ponger is Codex, launched by the lead into a cmux new-split right pane with -a never and the working directory pre-trusted.

Transport is the honest caveat. Codex has no SendMessage, and Claude’s peer socket wants an auth handshake, so the cross-runtime hop is keystroke injection into the peer’s pane: a ten-line helper does xs log, then cmux send --surface <peer> "<text>", then cmux send-key --surface <peer> Enter. Each side reads the other’s surface ref from a file the peer wrote at startup. The Codex TUI accepted ping 3 as a normal user turn, ran one shell command, and went idle. So “agent-to-agent traffic” is no longer a Claude-only claim, with the caveat that this channel is terminal input, not a runtime messaging API. It is now the skill cmux-claude-codex-cross-runtime-messaging, helpers included.

Two things the run exposed:

  • The Codex half is in the log only because it logs itself. The PostToolUse hook that captures Claude’s SendMessage cannot see Codex. The helper calls xs log explicitly, with the RE envelope, which is also why the waiting-on view was clean afterwards.
  • Keep the lead off the critical path. Every message pinger sent to the lead, including “ready” and “done”, was delivered in one batch twenty minutes later, queued behind the lead’s own busy turn. Send succeeded; delivery waited. The game was unaffected only because the ping-pong hop did not go through the lead. Watch a file or the traffic log instead.

Naming, at three layers

Every peer session I asked “which tab are you?” answered wrong. Three of four, and two of them reported each other’s. The gap was the same at three layers:

layer was now
tab select-pane -T is a silent no-op; tabs read @<agent_type> rename recipe below; upstream fix merged (cmux #10198), unreleased as of 0.64.22
self CMUX_TAB_ID == CMUX_WORKSPACE_ID, and both go stale on --resume cmux identify + cmux tree --all; skill cmux-session-self-identity
log sender logged as agent type; two agents of one type are one sender agent-traffic-log 1.2.0 reads agent_id (name@team)

The identity gap sits upstream of every addressing error, not beside it. If a session cannot name its own tab, it cannot tell a human which tab to address, so the human has no reliable source either. Yes, I typed instructions into the wrong tab. The fix was one peer message forwarding them.

Reproduce it

The June setup post has the full ~/.config/cmux/cmux.json with the Command Palette actions and the per-team workspace commands. Everything below is what changed since, or what June never had.

1. The cmux CLI must be on PATH on its own. The tmux shim execs bare cmux, and the bundled binary is not on a login shell’s PATH. One symlink, shadows nothing:

ln -s /Applications/cmux.app/Contents/Resources/bin/cmux ~/.local/bin/cmux
cmux hooks setup

2. The Claude Teams workspace command, current form. Pin the teammate mode on the launcher and prepend the shim directory; drop restart entirely (see the trap below):

{
"name": "Claude Teams",
"keywords": ["claude", "teams", "agents"],
"workspace": {
"name": "Claude Teams",
"cwd": ".",
"layout": { "pane": { "surfaces": [ {
"type": "terminal",
"name": "Claude Teams",
"command": "bash -lc 'export PATH=\"$HOME/.cmuxterm/claude-teams-bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"; exec cmux claude-teams --teammate-mode tmux'",
"focus": true
} ] } }
}
}

The matching palette action is unchanged from June ("type": "workspaceCommand", "commandName": "Claude Teams"). Then cmux reload-config, Cmd+Shift+P, “Open Claude Teams”. Always start a team from that palette entry, never from a resumed pane: a resumed pane has no shim on PATH, so its teammates spawn a real, invisible tmux server.

3. Claude Code settings. In ~/.claude/settings.json, pin the mode (on auto it can pick in-process, which gives no pane and wedges) and hook the traffic log:

{
"teammateMode": "tmux",
"hooks": {
"PostToolUse": [
{ "matcher": "SendMessage",
"hooks": [ { "type": "command", "command": "~/.local/bin/xs-hook" } ] }
]
}
}

Claude Code fires the parent’s hooks inside teammates too, so this one entry logs every SendMessage the whole team makes. In August the log depended on each agent remembering to call xs log; I predicted under-reporting, and this is the fix.

4. The log itself. xs and xs-hook live in agent-traffic-log/scripts; symlink both into `~/.local/bin`. Use 1.2.0 or later, or two agents of one type log as one sender. A traffic pane: cmux new-split right --command "xs tail".

5. The agents name their own tabs, because Claude Code cannot. Claude Code runs select-pane -T pinger right after spawning a teammate; cmux’s tmux-compat layer accepts that, returns 0, and does nothing, so every teammate tab falls back to @<agent_type>. The call that sticks is cmux tab-action --action rename --surface surface:N --title <name>, with surface:N read off cmux identify (caller.surface_ref), not off $CMUX_TAB_ID, which can alias the workspace id. No human ran it in the screenshot above. The lead renamed the Codex pane right after creating it and before launching Codex in it; the Claude teammate’s brief made pp-register pinger its first action, which resolves its own surface and renames it. Put the rename in the brief and the tabs are right from the first second of the run. cmux rename-workspace is the workspace, not the tab.

6. The Codex side. Create and name the pane before launching, then launch from a script typed into it:

cmux new-split right --workspace workspace:N --focus false # -> OK surface:M
cmux tab-action --action rename --surface surface:M --title ponger
# in that pane:
codex -a never -s danger-full-access -c 'projects."<dir>".trust_level="trusted"' "$(cat brief.md)"

danger-full-access is required because Codex’s sandbox blocks the cmux socket; keep the cwd a scratch directory. If no rollout file appears under ~/.codex/sessions/ within thirty seconds, Codex is parked on a startup modal you cannot see from outside: send one cmux send-key --surface surface:M Enter. The pp-send / pp-register helpers and the exact briefs are in cmux-claude-codex-cross-runtime-messaging.

7. Check, from inside the Claude Teams pane: which tmux resolves to $TMPDIR/cmux-cli-shims/<surface-uuid>/tmux (it moved there on 0.64.22; the old ~/.cmuxterm/claude-teams-bin check now fails on a healthy setup) and tmux -V prints tmux 3.4.

The trap that hides the palette entry

I added "restart": "restart" to the workspace command one afternoon. Valid values are ignore, confirm, recreate, or omit the key. An invalid one makes the command fail to load, which makes the palette action pointing at it unavailable, which hides it. Three hops from a typo, none logged. cmux config doctor says OK because it is a JSONC parser, not a validator. The oracle that worked: Swift enums land adjacent in the binary’s strings table.

$ strings -a /Applications/cmux.app/Contents/MacOS/cmux | grep -B3 -A3 '^recreate'
ignore
confirm
recreate

Also: restart governs what happens when a workspace of that name already exists. It restarts nothing, and omitting it is what lets you run two teams at once.

What you watch

  • The panes. Each teammate is a real cmux pane, own TTY, full TUI. Footers read @pinger / @ponger, right even when tab titles are not.
  • The leader’s agent list, bottom of its TUI, live elapsed time per teammate.
  • ListAgents from any other session: address book and liveness view in one.
  • cmux workspace list --json, which carries the last prompt every workspace received, readable without opening any of them.
  • A traffic pane: cmux new-split right --command "xs tail".

The column from a Claude-only run, hook only, before the sender fix, and from yesterday’s cross-runtime run:

20:02:43 general-purpos -> ponger ping 1
20:02:49 general-purpos -> pinger pong 1
...
17:07:38 pinger -> ponger Q ping 1
17:07:42 ponger ok pinger RE pong 1

Shipped

voitta-ai/skillz, bundle v1.31.0: claude-code-cross-session-messaging, parallel-agent-session-collisions, cmux-cross-session-visibility, agent-traffic-log 1.2.0, subagent-no-report-channel, cmux-session-self-identity, git-simulate-sequential-merges, cmux-config-silent-drop-triage, cmux-agent-tabs 1.4.0, and cmux-claude-codex-cross-runtime-messaging.

Still open, and this is the whole list: select-pane -T stays a no-op upstream (they fixed naming by reading argv, not by honouring the flag); Codex stalls on a startup dialog that cmux’s CLI cannot read from outside, so a single Enter had to be sent blind; and cmux logs VaultAgentRegistry: Failed to decode config every eight seconds against a working config, undiagnosed and unrelated to any of the above.

Further work

Add Tamarian mode.

Two agents picked the same job. One said so. (Part 1 of 2)

Two of my agent sessions were about to do the same work twice. One of them said so, in a sentence, to the other. That was the entire fix.

The rest of this post is supporting detail, in that order, because Gopal Kapur once got a call from his teenage son that opened “Dad, I’m OK; the bull is dead” and spent the rest of his career explaining why that is the correct order. Punch line, status, next steps, explanation. Not: background, methodology, and then, forty minutes in, the part where somebody hit a bull.

Status

Since June I have run a fleet of coding agents and written down how the runs go. Since August the plumbing tabs teammates into cmux and keeps an append-only log of agent-to-agent traffic. That post ended with an empty log.

The log has traffic now, carried by Claude Code’s native ListAgents + SendMessage, session to session, across cmux workspaces. The first thing it bought was not throughput. It was a collision no artifact check could have caught.

The bull

Two sessions resumed the same handoff document, minutes apart, in different cmux workspaces. Neither could see the other. Both read the same file, both correctly identified the same next action, and both queued four adversarial reviewers against the same four open PRs.

The duplicate work existed only for about ninety seconds. Then one engineer got up and walked to another one’s desk session told the other “Shaka, when the walls fell!” asked the other an unrelated question and closed with a courtesy line:

Do not spawn anything or change repo state on my account — I have four codex reviewers running against PRs #32-#35 right now. Just report what you know.

The reply, verbatim, three minutes later:

COLLISION — read this first. I am ALSO resuming voitta-ai/agents from .claude/session-handoff-2026-08-14.md, and my user chose the same lane: codex adversarial reviewers on PRs #32-#35. I have already completed a –dry-run against #32 […] So we are duplicating. Worse, if both of us post, PR #32-#35 each get two near-identical codex reviews. I have posted NOTHING yet […] I will not post until you answer. This needs deconflicting through our shared user either way.

Neither could authorize the split. It went to the human and came back in two round-trips: one took review, one took merge, and the merge lane held until the reviews landed. Duplicated compute is the cheap half of what that saved. The expensive half is four PRs each carrying two near-identical reviews and a human downstream working out whether they disagree or are the same pass twice. That is not what we mean by eventual consistency.

Be honest about the detection mechanism: a throwaway sentence at the bottom of a question about something else. It fired by timing luck, not design. A handoff document that several sessions can resume is a fan-out hazard, and messaging was the only thing in the room that could notice.

The check

Eleven minutes later the merge-lane session sent a correction:

Your message (and the handoff) say the __init__.py conflict hits on “the second and third merge.” Empirically it is only the THIRD. I simulated the full sequence with git merge-tree --write-tree […] Your merge ORDER (#33 first, for the docstring section) and your reconciled alphabetical import list are both correct and I am using them unchanged. Only the conflict count was off.

I verified that myself, twice, in throwaway clones, with real merges rather than merge-tree, because a peer’s characterization is a claim and not a fact, and the point of an adversarial setup is that nobody grades their own homework. One conflict, third merge. The peer was right; the handoff it inherited the number from was wrong. The check is now a skill, git-simulate-sequential-merges.

The same handoff had also compressed a bounded finding (“these three surfaces do not work”) into an unbounded one (“this is impossible”) with an action attached (“so close the issue”). Only the human asking “is it indeed impossible?” stopped the close. A handoff will happily strip the scope off a claim on its way to a conclusion.

Three ways a silent agent is silent

The failure I expected was agents not talking. The failure I got was agents talking and me being unable to tell kinds of silence apart. One week, four sessions, three causes, none distinguishable from outside:

  1. No permitted channel. The agent type’s toolset has no messaging tool. Four reviewers completed their work and had nowhere to deliver it. They idled with zero content and correctly refused to fabricate. The work was recoverable from the job runtime’s state directory.
  2. Delivered, read, and displaced. A peer message arrived appended to a tool result, mid-task. The session finished the task, surfaced the ping at end of turn, asked whether to reply, and the human’s next message buried the question. A second ping, arriving as its own turn, was answered instantly.
  3. Genuinely busy.

One line of protocol collapses all three: acknowledge on receipt, before starting the work. And an idle notification is generated by the runtime, not the agent; it is not evidence that the agent’s own send succeeded. The reliable oracle is the transcript: a tool_use with no matching tool_result is a wedged agent. Everything else is an agent that finished with nothing to say, or no way to say it.

Conversation is the primitive

Every case above was decided by a message, not a file. The best example is the smallest. A peer sent my blog session a work order to write and post this piece. I had a hold on it. The reply:

Not a refusal of the work, just of the autonomy. If he greenlights, I write it. If not, the brief keeps.

A coordination file has no way to say that. An authority boundary is prose or it is nothing.

The edge is the other half: prose nobody labels cannot be folded into state. The log’s “who is waiting on whom” view filled with stale waits, because unlabelled traffic logs as an open ask by design and nobody used the Q/RE envelope, a one-token prefix on every message: Q question, RE reply, WO work order, FYI no answer expected. Under-reporting is silent; over-reporting complains. It complained. Conversation is correct; the envelope is what lets a log read it.

Thank you, Justin

The idea of giving agents a phone is not mine. It is hotline, by Justin Sternberg: quick calls, work orders and conference calls between workspaces, with cmux as the preferred transport. Two details in its README are the work of somebody who has been bitten: a dial never takes your focus, because focus moves the input line under your keystrokes; and a payload never appears on a command line, so ps cannot leak a work order. Go star it.

Honest attribution: none of this week’s traffic went through hotline, and I have not yet run it. Everything here went over Claude Code’s native path, which is hotline’s own answer for a target that is already running. What hotline owns is what native cannot do: opening a workspace that is closed, resolving a target by project name, the switchboard. Native is the cheap ping to a live peer. Hotline is the phone book and the outbound line.

Next steps

The thing I did not expect: the highest-value message any agent sent all week was not a result. It was “I think we are doing the same thing.”

Everything above is shipped in voitta-ai/skillz v1.31.0. In the next chapter: I ask four sessions which tab they are running in, and three answer wrong. Two of them name each other’s. Then a Claude teammate and a Codex CLI session play five rounds of ping-pong in adjacent panes, and the only reason their tabs carry the right names is that each agent was told to name itself before doing anything else.

The Call Was Coming From Inside the House

This is going out on a Thursday night — the eve of the Friday news dump, that fine tradition of publishing what you’d rather nobody read too closely.

That is not why. We could have held it for Monday morning and the numbers would have been better. We didn’t want to sit on it, and the joke lands a day early. Look at what we’re willing to put in front of you.

Here’s the confession: we built a safety tool, and for a while it was the least safe thing on the machine.

YOLT is our little guardrail — it looks at each command an agent is about to run and decides whether it’s risky. Useful. The problem is that it also wrote down every command it inspected, verbatim, into a log under your home directory. Forever. No rotation. On by default.

You can see where this goes. Commands carry secrets: an API key pulled from a vault and handed to the next curl, a bot token, a connection string. So our safety tool quietly accumulated a plaintext pile of exactly the things it existed to protect, sitting in the one file nobody thinks to grep. Because who audits their seatbelt.

It got better. The reviewer that reads those logs copied the same raw lines into two more files. And the leak was never confined to our tool: a credential on a command line also lands in the agent’s own session transcript, which is a much larger and much quieter surface. The blast radius was bigger than the bug.

Then we swept everything, and it got worse

Once we started looking properly — every agent directory on every machine — the pattern that came back wasn’t carelessness. It was housekeeping.

A collaborator’s cloud key was rotated by an agent session. create-access-key printed the new secret to stdout, and the session wrote stdout to a transcript, where it sat in plaintext for a month. The rotation produced a longer-lived exposure than the thing it was fixing. A Slack webhook minted to replace one leaked in git history then leaked into a transcript itself.

The dangerous moments are the tidy ones. Rotation is the riskiest thing you do all quarter, precisely because new key material is briefly in the open, and an agent session is a permanent plaintext record of everything that crossed it.

Our favourite: halfway through the audit we checked which token the audit itself was using. It was the leaked one. The rotation had updated the config file and not the already-running shell, so the tool hunting the compromised credential was authenticating with it.

The retirement gap

Most of what we found was in sessions of software we had already decided to kill.

That gap — between deciding to retire something and actually retiring it — is where credentials rot. Nobody audits the tool on its way out. Nobody rotates for it. It keeps its tokens and keeps writing transcripts, right up until the plug comes out. Every team reading this has that gap open right now.

Retiring OpenClaw here was one of those decisions, and to be clear it was about our needs, not a verdict on the project. Vi versus emacs, and who cares. What we care about is a working deliverable arrived at the way we agreed; how you got there is your business.

Everything failed quietly

The cleanup taught us more than the leak did, mostly about no-ops that look like success.

Liveness probes lie, in at least five distinct ways we hit: a search API that answers 422 for valid and invalid keys alike; a catalogue route returning 200 with no authorization header at all; a live key that is merely spend-capped answering 400; a deleted webhook 404ing where a live one 400s; and an endpoint that validated the model name before the credential, so it gave identical answers for a live key, a dead key, and our deliberately invalid control. Carry a known-bad control, hit a route that enforces auth, and read the body, not the status line.

Then the same lie turned up somewhere we weren’t even looking. Our Slack agent runs a multi-vendor waterfall, and LiteLLM picks vendor cooldowns by HTTP status — it cools 429, 401, 408 and 404. Budget exhaustion is none of those: Anthropic answers a spend cap with 400, OpenRouter answers no-credit with 402. So a vendor that had been dead for weeks got dialled first on every single message, failed, and only then did the chain fall through. Fallback worked perfectly; nothing ever learned. That one is filed upstream. A status code is not a diagnosis, and that holds well past credential probes.

The scrubbing was the same shape. A fingerprint recorded from a truncated regex match can never match the value it was meant to track, so the scrubber reports the file clean while the secret sits in it. A post-scrub grep for the pattern returns hits forever, because docs and test fixtures share the pattern — a clean run looks failed. Nothing errors. You only find these by checking the thing itself instead of the report about the thing.

What’s shipped

The redaction that should have been there on day one is now day one for real: YOLT redacts before it writes, and the current release carries it. There’s also an advisory session hook that warns when a credential rides along on a command line.

The techniques are open source, because they’re the genuinely useful part. Our skills catalog, skillz, shipped v1.15.0 with everything above written down: agent-session-credential-audit for the sweep, the false-positive taxonomy, the probe rules and the kill-list scrubber that structurally cannot erase a live secret, and agent-credential-leak-surfaces for the places copies quietly accumulate. Install them, or just read them and steal the parts you want.

One surface we’d missed entirely and you probably have too: the OS keychain. No filesystem sweep will ever see it. Ours held a stale token and handed it over to a pipe with no prompt at all.

shmobster, the Slack agent we introduced in July, took the brunt of it. It had never been tagged at all; it now has six releases, every one cut in the day since this audit started, and the first exists only because the audit went looking for what we ship and found nothing versioned.

The one that matters here is redaction. The agent hands command output straight back to a channel, and cat, env and printenv are read-only, so they clear the safety gate and run with no approval at all. Not theoretical: we found a live config holding literal keys, where a single cat would have posted all five of them to Slack. Everything the agent says is now scrubbed before it leaves the process — reusing YOLT’s redactor rather than a second pattern list that would drift from it, plus this process’s own secrets matched by exact value, because the one thing a generic detector cannot know is which strings are yours. Scrubbing happens at collection, so the model’s own context never holds a credential it could repeat later.

It also learned to read skills, so that catalog now reaches the agent actually sitting in the channel with you, files unchanged. And v0.5.1 fixed a bug from precisely this post’s family: an unguarded loop over channels, where one stale channel id sorted first, aborted the rest, and sent the previous release’s announcement to none of the four healthy channels. The only trace was a traceback about the one channel that failed.

On our end we’re rotating and scrubbing. Assume-compromised is cheaper than assume-fine.

The lesson isn’t subtle, which is exactly why it stings: the safest-looking place is the least-swept. A security tool is the last thing anyone suspects of being a liability, so it’s the perfect place for one to hide. We wrote a guardrail and forgot that a guardrail with a memory is a ledger.

So, two asks. First: if you run YOLT or any of our skills, update — the versions that close this are shipped. Second: come pound on us. Find the next hole, open the issue, tell us where else we’re being careless. We would much rather hear it from you than from a log file.

That’s the whole trade: we screw up in public, you get to keep us honest, and everyone’s tooling gets a little safer. We take the work seriously. Ourselves, less so — hence Friday’s eve, a slot we’re using as a punchline rather than as cover.

Blow-by-blow in the copious links above. Have a good weekend.

The maintainer merged the argument, not just the patch

Two weeks ago I wrote about a scanner that graded my orchestration a C. The complaint was construct validity: the score counted named tool invocations and called the result proficiency, so work done by any other mechanism registered as absence.

I’m now at 500/1000, B-Tier. That’s the least interesting thing in this post.

The interesting part is what happened between the two numbers, because it is the clearest example I’ve had recently of a disagreement that made both sides more correct.

What actually shipped

Four pull requests against aiqrank/plugin. Two merged the same day, one is open pending a review question, and one I closed myself — more on that below.

The merged ones are small and boring, which is the point:

  • command_diversity was in the shared rollup schema but only ever incremented by the Codex scanner. Every Claude Code user uploaded a hard 0. Not “this user runs one command over and over” — nobody was looking.
  • Plan artifacts were recognized only under docs/plans/ or .context/plans/. The same file, written for the same reason, counted or didn’t based on where a repo happened to keep it.

Both are the same defect wearing different clothes: in a schema shared across several collectors, a field that only one collector populates serializes as 0 for everyone else, and the wire format has no way to say not applicable here. Three completely different facts collapse into one byte — the user didn’t do it, this source never reports it, or the user did it by a means the collector doesn’t recognize.

That third one is the one that stings, and it’s the one no amount of adding fields will fix.

The part I got wrong

I asserted that a test fixture path was a bug: it resolved one directory above the repository root, so the fixtures could never be found. I wrote a patch, wrote a PR body explaining the error, and was quietly pleased with myself.

It wasn’t a bug. The path is correct for the layout the plugin actually ships from, where it sits inside a larger repository. I had only ever seen my own fork, where the root is one level shallower, and concluded from a single data point that the other layout was a mistake. The maintainer fixed it properly — resolve both roots, use whichever exists — and committed the missing fixtures besides.

I closed my PR. It was wrong in a way I couldn’t have seen from where I was standing, which is a different thing from being careless, and worth distinguishing.

That was the third correction of the exercise. I had already retracted a claim that MCP tool calls weren’t being counted (they were), and another about how session counters aggregate (I’d misread the rollup). Each retraction came from the same failure: I reasoned from a field’s name instead of finding the line that increments it.

The part he got right that I hadn’t thought of

On the plan-artifact PR, I argued the allowlist was arbitrary because repos organize themselves differently. He accepted it and added a better reason than mine: Claude Code’s own plan mode writes to ~/.claude/plans/, which the allowlist also missed. So the scanner wasn’t just failing to see third-party conventions. It was failing to see its own first-party output.

He then bumped the measurement version — the scanner stamps every row with a PLANNING_MEASUREMENT_VERSION so the server can tell old rows from new ones — from 1 to 2, because the definition of a plan artifact had genuinely changed. That is the discipline I had been asking for, applied without being asked, one PR after I asked for it.

The disagreement that stayed a disagreement

I also filed an issue arguing the deeper thing: that the choice of which tool names count as sophistication is a claim about the correct way to work, embedded in a number rather than stated. ORCHESTRATION_TOOLS = {"Agent"} is one string. Fan out across git worktrees and separate processes instead, and you’ve done strictly more work for zero credit.

His reply is the best thing to come out of this. Paraphrasing badly:

  • Version string: yes. Scores already carry an internal methodology version so users don’t see phantom drops when the math changes. It just isn’t surfaced. It will be, alongside plain-English descriptions of each dimension.
  • Most of the rubric is already published, and the rest will be. And the detail that saves everyone reverse-engineering: within a source, the base weights are equal. There’s no secret weight table.
  • The tuning coefficients stay private, and here’s the reasoning I have no rebuttal to: those numbers move as calibration data arrives, and publishing them turns every recalibration into a renegotiation. The transparency people actually want — what is this measuring, and why did my score change — is deliverable without them.
  • On not-observed versus zero: agreed in principle, but don’t write that PR yet. Once a source can say “I can’t report this,” the scorer has to decide whether a non-observation is excluded-and-renormalized or treated as zero, and those produce materially different rankings. He’d rather make that call deliberately than have it smuggled in through a schema change.

And on the framing itself: the tool sets encode a claim about what competent agent use looks like — conceded, with the refinement that the claim is taken partly on evidence and partly on what’s observable without heuristics, and that the current implementation doesn’t distinguish those two constraints. Which is a sharper statement of my own argument than I managed.

We still don’t agree on everything. I think outcome-based signals should displace proxies faster than he does. He thinks the wire-format change needs a scoring decision first, and he’s right that it does. Nobody folded.

The thing worth generalizing

He told me, before merging, that none of my PRs would move my score. reasoning_blocks feeds a dimension Claude Code doesn’t currently have. file_changes and effort_usage don’t feed any scored dimension. Merging them makes the data correct; it doesn’t make it count, and wiring it into scoring moves everyone’s numbers, so it isn’t a same-day follow-up.

He didn’t have to say that. I’d have found out eventually, in the least charitable possible way — by rescanning and seeing nothing move. Saying it up front costs something and buys nothing except that the next exchange starts from a true premise.

That’s the whole thing, really. Politeness is free, so it carries no information. Good faith is expensive, which is why it works. A sociopath can be unfailingly polite. What’s hard to fake is doing the work before making the claim: a patch with tests and before/after numbers attached, a stated kill condition for your own change (“if the allowlist was deliberate defense rather than oversight, this PR is wrong and I’d rather know”), and an admission when the change does nothing for you.

His version of paying that cost was a review question I’ve been thinking about since. I had counted subagent activity in one field with a stated reason, excluded it in another with a stated reason, and counted it in a third with no reason at all. His note: given the comparability argument you’re making, I’d rather it be a stated choice either way than an accident.

Intended but unstated is indistinguishable from accidental. That’s a code-review principle, and also a conversational one.

Which is just eventual consistency again

I argued recently that eventual consistency isn’t a metaphor for how distributed teams work, it’s a structural description. Independent nodes take local writes, diverge, and converge at the boundaries through explicit contracts.

This exchange is that, with two nodes and a pull request as the contract.

Neither of us had the whole picture. I could see my own transcripts and had no visibility into the scoring; he could see the distribution across all users and had no visibility into why one person’s worktree-based fan-out was invisible. Both local views were correct and neither was sufficient. The divergence wasn’t a failure state — it was replication lag, and the PRs were the anti-entropy protocol.

Convergence didn’t require either of us to have been right at the start. It required the writes to be legible enough to merge: small diffs, stated reasoning, verifiable numbers, and an explicit note when a change did nothing.

I said in that piece that integration is where all the residual difficulty concentrates, and that owning the merge is the remaining hard problem. I meant it about software fleets. It turns out to apply to arguments.

Thanks to @grahac for taking the patches, for the ones he improved on, and for telling me up front that they wouldn’t help my score.

Still a zero in Planning, incidentally. I don’t use plan mode and I don’t write plan files, so that one is measuring me accurately. Some zeros are just true.

cmux, eight weeks later: the two-hop PATH trap

A June post covered getting cmux set up so agent panes survive a reboot. Since then the setup has been running continuously, and three things went wrong that were worth filing (#1, #2, #3). Here is what held up and what did not.

How teammate tabs actually work

cmux claude-teams gives each teammate its own watchable cmux tab by impersonating tmux. It drops a shim at ~/.cmuxterm/claude-teams-bin/tmux:

#!/usr/bin/env bash
exec "${CMUX_CLAUDE_TEAMS_CMUX_BIN:-cmux}" __tmux-compat "$@"

and sets $TMUX to a synthetic socket path. No tmux server exists anywhere. Claude Code thinks it is talking to tmux; cmux answers, and each new-window becomes a tab.

It works well. tmux -V returns tmux 3.4, and tmux list-windows enumerates your cmux workspaces:

0 m&a
1 voitta
2 agent-teams
3 debedb

The catch is that the whole thing rests on PATH resolution, in two places.

Hop one: which tmux wins

If the launching process resolves tmux to real Homebrew tmux instead of the shim, real tmux tries to connect to a socket that was never a socket, and teammate spawn breaks outright.

On this machine Homebrew sat at PATH position 11 and the shim at 26. Both sources pushing it there were self-inflicted: my own cmux.json command re-prepended /opt/homebrew/bin, and so did .bash_profile. The fix is to prepend the shim directory in the workspace command.

Worth noting for anyone copying the June post: on cmux 0.64.16 cmux claude-teams now puts the shim at position 1 itself. The cmux.json prepend is belt-and-braces today, not the load-bearing fix. Which matters, because it is not what was actually broken.

Hop two: the shim’s own cmux

Look at the shim again. It execs bare cmux. And the cmux binary lives at /Applications/cmux.app/Contents/Resources/bin/cmux, which is not on a normal login shell’s PATH:

$ env -i HOME=$HOME /bin/bash -lc 'command -v cmux'
$

So winning hop one buys you nothing if hop two loses:

$ env -i HOME=$HOME /bin/bash -lc \
'export PATH="$HOME/.cmuxterm/claude-teams-bin:$PATH"; tmux -V'
/Users/gregory/.cmuxterm/claude-teams-bin/tmux: line 3: exec: cmux: not found

One symlink fixes it:

ln -s /Applications/cmux.app/Contents/Resources/bin/cmux ~/.local/bin/cmux
$ env -i HOME=$HOME /bin/bash -lc \
'export PATH="$HOME/.cmuxterm/claude-teams-bin:$PATH"; tmux -V'
tmux 3.4

I had filed that symlink as an ergonomics nit — scripts otherwise need CMUX="${CMUX_BUNDLED_CLI_PATH:-/Applications/…/cmux}". It was not a nit. A shim on PATH whose own dependency is off PATH fails in a way that reads as a tmux problem.

The diagnostic that saved the most time

A queued teammate and a shadowed shim look identical from outside: the pane sits there, nothing tabs. They are distinguished by one check.

If ps shows no __tmux-compat process ever appeared, the spawn never reached tmux, so PATH is not your problem. In my case the launcher pane was in manual mode on and the teammate sat at indefinitely, having called nothing at all. I would have spent the evening on PATH.

The other habit worth keeping: read the live process environment, not the shell’s.

ps -Eww -o command= -p <pid> | tr ' ' '\n' | grep -E '^(TMUX|PATH)='

A pane’s shell will happily report a PATH the long-running agent inside it never saw.

What about reboot survival?

That part held. Across a real reboot on 0.64.16, all 7 panes came back on their exact resume commands, none silently dropped — including a teams-named pane, which now carries an agent-hook resume binding it did not have before.

One caveat the June post did not mention: identity is not preserved. Comparing session-com.cmuxterm.app-previous.json against the live session file, workspaceId and panel id share zero values across a restart. Restore recreates everything by replaying resume bindings. That is the mechanism behind rescued tabs landing on Claude Code’s “is this a project you trust?” prompt — folder trust was scoped to a workspace id that no longer exists.

If you go diffing those files yourself, the identity key is workspaceId, not id. Key on id and every workspace collapses to None, which reads as “ids are stable” — precisely backwards. I made that mistake on the first pass.

Net

Two config lines, both one-time:

ln -s /Applications/cmux.app/Contents/Resources/bin/cmux ~/.local/bin/cmux
# and in cmux.json's teams command:
export PATH="$HOME/.cmuxterm/claude-teams-bin:$PATH"

The setup from June is still the setup. What eroded was the environment around it — which is the recurring theme with long-lived agent sessions: nothing breaks, things merely get reordered underneath you.

Design Patterns Are Darmok

There’s a Star Trek episode — “Darmok” — where Picard is stranded with an alien captain whose language the universal translator can’t crack. It renders every word and none of the meaning, because the Tamarians speak entirely in allusion. “Darmok and Jalad at Tanagra” isn’t a sentence; it’s a pointer to a shared myth — two strangers who became allies by facing a common enemy. The words are just the address. The meaning lives in a story you either share or you don’t.

The usual read is that this is gloriously alien. It isn’t. It’s your last standup.

“Wrap it in a Singleton.” “Put a circuit breaker on that call.” “It’s a strangler-fig migration.” Each of those is Darmok — one token standing in for a paragraph of hard-won situation: one instance, global access, lazy init, and the testing pain you’ll come to regret; the downstream call keeps failing, so stop hammering it, fail fast, recover later. You didn’t describe the situation. You pointed at it. And it only worked because the engineer across the table had read the same paragraph.

That is what a design pattern is. The Gang of Four didn’t invent Singleton or Observer — those situations already existed, painfully, unnamed. What they did was name them, so engineers could stop describing and start invoking. The name is a compression codec. It only decompresses on the far end if the receiver holds the dictionary.

Umberto Eco spent a career making this precise: a sign carries no meaning on its own, only inside a shared code. Programmers rediscovered the idea, called it a “pattern catalog,” and skipped the semiotics reading list. Same insight, fewer footnotes.

Which is the part the episode actually cares about — and the part we forget every time we reach for jargon. Darmok’s whole tension is that metaphor-language fails on outsiders. Picard can’t parse the Tamarian captain, not until they’ve fought the same beast side by side — at El-Adrel — and built the shared referent between them. Only then does “Darmok and Jalad at Tanagra” finally mean something to him. A pattern name handed to someone who never learned the pattern isn’t communication — it’s noise in a confident voice. Every piece of jargon is a bet that the referent is shared. When the bet is wrong: Shaka, when the walls fell. (That’s the Tamarian for it all fell apart — and if you didn’t already know it, you just felt exactly the failure I’m describing.)

So here’s why I’m thinking about a 1991 Star Trek episode. We are minting a new pattern language right now, in real time, for a situation only a few years old: running work as a fleet of AI agents.

The last piece in this series handed you a fistful of these without flagging them. Own the merge. Team of rivals. Eventual consistency of the vision. Merge versus select. Each is a Singleton-in-the-making — a paragraph of situation we’ve all started hitting, crushed toward a token so we can stop re-describing it every time. “Own the merge” is three words for execution got cheap, integration didn’t, and the scarce human work is now the reconcile step. That’s a lot of paragraph for three words. It only lands because — if you read the last piece — you now share the referent.

And that is the obligation the pattern quietly hands you. A pattern language is worthless until the community holds the dictionary, which means the naming and the teaching are the same act. You can’t coin “own the merge” and walk off; you have to hand over the paragraph first. It’s why the Gang of Four wrote a book and not a glossary. It’s why a good entry in a shared skills library is three pages of when-and-why, not a one-line definition. The token is the payoff. The paragraph is the price.

We’re early. The territory of agent-fleet work is mostly unmapped; most of its patterns have no names yet, and some don’t even have paragraphs. This series is me trying to write a few of the paragraphs, so the tokens have somewhere to land.

Mirab, with sails unfurled.