Magic Tools
Developer ToolsBy CooconAugust 4, 2026113 views7 min read

Half My openclaw Commands Ran, Half Didn't — It Looked Like a Permission Classifier, It Was launchd

Two Claude Code windows, one Mac, one user, one global settings.json. Window A sat in ~/4khz/tingease, window B in ~/4khz/tingease/AudioCore. Same openclaw command. B ran it fine. A kept failing.

The user's read was the natural one: "Your classifier works — the other window can't execute."

That suspicion is well earned. Claude Code's auto permission mode really does call a model to judge each Bash command before letting it through, and when that judgment chain breaks, everything gets blocked. We've written up that exact trap before (linked at the end). But this wasn't it. The cause sat in a completely different layer — and what sold the permission theory was the shape of the failure.

The counterintuitive part: not dead, half dead

Both of these ran in the same window seconds apart. The first one worked:

$ openclaw --help
OpenClaw 2026.7.1-2 (0790d9f) — All your chats, one OpenClaw.
Usage: openclaw [options] [command]
...

The second didn't:

$ openclaw health
[openclaw] Could not start the CLI.
[openclaw] Reason: gateway closed (1006 abnormal closure): no close reason
Gateway target: ws://127.0.0.1:18789

Same binary, some subcommands fine, others dead. That shape misleads badly. A network outage or a crashed binary takes everything down; the fact that anything printed at all proves the binary, the PATH, and the exec bit are all healthy. Of the remaining explanations, "something is filtering commands one by one" sounds the most sensible.

Slice it differently and it resolves. openclaw's subcommands fall into two natural groups:

Type Examples Touches the gateway?
Purely local --help, daemon status, plugins list No — reads files and config directly
Gateway-bound health, gateway status, channels, agent Yes — dials ws://127.0.0.1:18789

Everything that worked was in group one. Everything that failed was in group two. That's not filtering, that's one shared dependency going down and taking its dependents with it. The dividing line has nothing to do with how dangerous a command is — it's whether the command opens that WebSocket.

That clue should have ended it immediately. It didn't, because I didn't look there first. What follows is the detour I actually took.

Hypothesis 1: the two windows have different permissions

The most direct explanation: window A's project directory carries extra restrictions. Two places to check.

The global ~/.claude/settings.json had this in permissions.allow:

"allow": ["Bash", "Read", "Edit", ...]

A bare Bash — no parenthesized qualifier, so every Bash command is allowed. It applies identically to both windows, because global config doesn't discriminate by project in the first place.

Then window A's project-level .claude/settings.local.json:

{
  "permissions": {
    "allow": ["Bash(cd:*)", "Bash(swift build:*)", "Bash(git status:*)", ...]
  }
}

An allow list and no deny. Project settings in Claude Code stack onto the global ones rather than replacing them, so a file containing only allow entries cannot revoke anything already permitted.

Hypothesis 1 is out. There is no permission-level difference between the two windows.

Hypothesis 2: that config file is corrupt

Step back a level. What if settings.local.json fails to parse? A BOM, a stray invisible byte, a JSON syntax error — a parse failure could plausibly degrade the whole project to confirm-everything mode.

Easy to falsify, two commands:

$ xxd .claude/settings.local.json | head -1
00000000: 207b 2020 0a20 2020 2022 7065 726d 6973   {  .    "permis

$ python3 -c "import json; json.load(open('.claude/settings.local.json')); print('OK')"
OK

The file is genuinely a little grubby — the first byte is 0x20, meaning there's a space before the {, plus two trailing ones. But there's no BOM (it doesn't start with EF BB BF), and the parser accepts it without complaint.

Hypothesis 2 is out. Leading whitespace is legal JSON. Ugly, harmless.

Hypothesis 3: the orphaned log entries mean commands were denied

This machine has a hook that records every tool call into ~/tools/logs/claude-hooks-*.jsonl. Grouping by session showed both windows cleanly:

('159f3a23', '/Users/duoduo/4khz/tingease')            37 entries   ← window A
('cb28e90b', '/Users/duoduo/4khz/tingease/AudioCore')  41 entries   ← window B

Reading window A's entries, something jumped out: several PreToolUse records had no matching PostToolUse.

[00:50:09] PreToolUse       ← unmatched
[00:50:33] PreToolUse       ← unmatched
[00:50:53] PreToolUse
[00:50:53] PostToolUse      ← properly paired
[00:51:03] PreToolUse       ← unmatched

The reasoning felt airtight at the time: PostToolUse only fires after a tool actually executes, so an orphaned PreToolUse is a call the permission layer killed. Not only did this confirm the permission theory, it came with second-level timestamps.

The reasoning was wrong, and wrong in a very ordinary way. A missing PostToolUse only tells you this call didn't complete normally. It does not distinguish a permission denial from a command that errored, timed out, or got interrupted. I treated a multi-valued signal as single-valued evidence — and the value I assigned happened to be the one I already believed.

The experiment that actually discriminates is to go to that window's directory and run the thing:

$ cd /Users/duoduo/4khz/tingease && openclaw gateway status
CLI version: 2026.7.1-2 (/opt/homebrew/bin/openclaw)
Gateway version: 2026.7.1-2
Runtime: running (pid 80719, state active)
Connectivity probe: ok
Capability: admin-capable

Hypothesis 3 is out. Same directory, same command, perfectly healthy. Not cwd. Not permissions.

The decisive evidence was a timeline, not a status check

That last command exposed the sharper problem: it's healthy now. Window A was broken then. A status snapshot cannot answer "what was happening at the time." Only a timeline can.

Put the hook log's timestamps next to what I measured when I picked the problem up:

00:49:58  [window A]  "go look at what's wrong with openclaw and fix it"
00:52:36  [window A]  "startup failed, please fix openclaw"
01:12:10  [window A]  "openclaw gateway status"
01:1x     [window B]  I take over; first diagnostic below

My first command was openclaw daemon status:

Service: LaunchAgent (not loaded)
Runtime: unknown (Bad request.
  Could not find service "ai.openclaw.gateway" in domain for user gui: 501)
Connectivity probe: failed
  connect ECONNREFUSED 127.0.0.1:18789

ECONNREFUSED means nothing was listening on 18789 at all. Across the entire stretch window A spent struggling — 00:49 to 01:12 — the gateway had never been up.

And the only reason window B "could run openclaw" is that my very first action was:

$ openclaw daemon start
Gateway LaunchAgent was installed but not loaded; re-bootstrapped launchd service.

I didn't invoke it more cleverly. I repaired the service. The two windows were never treated differently — they reached the same shared service at different times, and its state changed in between.

Root cause: installed, never loaded

That message says all of it: installed but not loaded.

launchd takes services in two steps. A plist sitting in ~/Library/LaunchAgents/ completes step one; it still has to be bootstrapped into the user's launchd domain to actually be managed. Here step one succeeded and step two never did, producing an awkward in-between state:

  • openclaw daemon status reads the plist and reports the service as installed
  • launchctl has no such label, hence Could not find service ... in domain for user gui: 501
  • nothing listens on the port, so every gateway-bound subcommand gets ECONNREFUSED

openclaw daemon start supplies the missing second step.

With that fixed, I checked the persistence settings so it wouldn't recur on the next reboot:

$ plutil -p ~/Library/LaunchAgents/ai.openclaw.gateway.plist | grep -E "RunAtLoad|KeepAlive"
  "KeepAlive" => true
  "RunAtLoad" => true

$ launchctl print-disabled gui/501 | grep openclaw
  "ai.openclaw.gateway" => enabled

RunAtLoad handles start-on-login, KeepAlive handles restart-on-crash, both true, and it had never been launchctl disabled. The configuration was sound — the unloaded state was a one-off, not a design flaw.

Because the service is machine-wide, fixing it restored both windows at once. Window A had to change nothing.

The general lesson

When identical environments give different results, check the shared stateful dependency before you check their individual configs.

Config belongs to each window. The service is shared by the whole machine. When the difference between two environments is time rather than settings, only a shared stateful component can explain it. I had that backwards — I spent my first minutes diffing two settings files and validating JSON, all of which is stateless, and stateless things don't break only during a particular window of time. One question would have redirected me in three minutes: what do these two windows share that can change on its own?

Three more:

  • "Some features work" is routing information, not filtering information. When class A works and class B doesn't, the first instinct shouldn't be "something is picking and choosing" but "these two classes take different paths — where do they diverge?" Find the dividing line — here, whether a WebSocket gets opened — and the root cause is usually already visible.
  • Absence in a log is a multi-valued signal. That's precisely where hypothesis 3 fell over. A missing PostToolUse could be denial, error, timeout, or interruption. Using a multi-valued signal to confirm a hypothesis you already lean toward is one of the easiest traps in debugging — it hands you confidence, not information.
  • A snapshot can't testify about history. "It works when I run it now" does not refute "it was broken then." Reconstructing the past takes timestamped records, cross-referenced against a second independent timeline — here, the hook log's prompt timestamps lined up against the ECONNREFUSED I hit when I took over.

The one-line version: when a CLI runs half its commands and refuses the other half, don't start by assuming something is screening them — go find the dependency that splits them in two. Most of the time the rejected half simply shares a service that has quietly fallen over.

Related reading: our piece on Claude Code's auto mode judging a model with a model describes what it looks like when the permission classifier genuinely fails — a useful contrast. There, "reads work, commands don't" was caused by the classifier. Here, an almost identical half-paralysis had nothing to do with permissions at all. Read together they train the instinct that separates them: are the failing commands grouped by how dangerous they are, or by what they depend on?

Related Articles

Service Up, Ports Open, Certs Valid, VPN Dead for 4 Hours: Tailscale Took Over DNS and Left the Proxy Box With No Upstream

A Los Angeles VPS running sing-box (VLESS-REALITY + Hysteria2) lost its VPN the day after Tailscale was installed. systemctl, ports and certificates were all fine. The root cause was in /etc/resolv.conf: Tailscale manages DNS by default, the tailnet had no global nameservers, and when dhclient renewed its lease tailscaled read an empty resolv.conf and dropped its upstream list. From then on every public domain got SERVFAIL, and the REALITY handshake could not even resolve www.apple.com. Full timeline, the evidence for each step, three fixes, and the rules we added to CLAUDE.md so an AI assistant (Claude Code) does not walk into this again.

claude-codetroubleshooting+8
pitfallsSep 17, 20266 min
33

Cracking Open Claude Code's Auto-Mode Classifier: A 116K-Char System Prompt, Dissected Line by Line

My earlier retest confirmed auto mode calls the session model as a classifier before each risky Bash — but what it receives stayed a black box. This time I captured the full request: a 116,879-char system prompt opening 'You are a security monitor for autonomous AI coding agents.' I quote it verbatim to dissect the threat model, two-tier rules (1 HARD BLOCK / 68 SOFT BLOCK / 17 ALLOW), and two-stage evaluation — stage 1 grades harm only, stage 2 layers intent on top. Every number read out this session.

claude-codepermissions+5
hands-onAug 30, 202612 min
234

Claude Code Says No conversation found to continue — Your -p Sessions Are Being Filtered Out

You run a headless claude -p command, then type claude --continue to pick it up interactively — and get No conversation found to continue, even though the session file sits right there on disk. This is a regression introduced in v2.1.90: the --resume picker's 'hide -p/SDK sessions' filter was mistakenly applied to --continue, which should continue the latest session unconditionally. We reproduced it end to end on macOS + v2.1.220 (the GitHub issue reports Fedora, so both platforms confirm), and verified two workarounds: headless -p --continue has always worked, and prefixing CLAUDE_CODE_ENTRYPOINT=sdk-cli lets interactive --continue bypass the filter with full history loaded.

claude-codeheadless+4
hands-onAug 6, 20263 min
454

The Claude Code Hooks Stdin Trap: Python Heredocs Eat Your Hook JSON

The docs say hooks receive JSON via stdin — true. But parse it with a python heredoc (python3 - <<'EOF') and you hit a silent failure: the heredoc redirects python's stdin to the script itself, so json.load(sys.stdin) reads nothing. A best-practice hook then swallows the exception and exits 0 — no error anywhere, just a hook that 'somehow does not work'. The real debugging session, the two-line fix, and a lesson: silently fault-tolerant code is your enemy at debugging time.

claude-codehooks+5
hands-onAug 6, 20263 min
221

Published by Magic Tools