Case Study / iMac Pro
A persistent Claude Code agent living in tmux on Will's machine, surviving reboots, reachable from his phone. Staged so you can stop cleanly at any point and still have something that works.
Open Terminal on the iMac Pro. Paste this whole block and hit return. It changes nothing, it only reports.
echo "=== macOS ==="; sw_vers
echo "=== arch ==="; uname -m
echo "=== hardware ==="; system_profiler SPHardwareDataType | grep -E "Model Name|Model Identifier|Chip|Processor Name|Total Number of Cores|Memory"
echo "=== toolchain ==="; for t in brew node npm tmux git claude; do printf "%-8s " "$t"; command -v $t || echo "MISSING"; done
echo "=== node arch ==="; command -v node >/dev/null && file "$(command -v node)" || echo "no node"
echo "=== sleep ==="; pmset -g | grep -E "^ *(sleep|disksleep|hibernatemode|womp)"
echo "=== messages ==="; ls -la ~/Library/Messages/chat.db 2>/dev/null || echo "no chat.db, Messages not signed in"
echo "=== remote login ==="; sudo systemsetup -getremotelogin 2>/dev/null || echo "needs admin"
Send that output to Jared's agent before continuing. Three answers in there change the rest of the walkthrough: the macOS version, whether the machine is Intel or Apple Silicon, and whether it already has a toolchain.
Read the macOS line first. It is a hard gate, not a preference.
brew install either compiles everything from source or fails outright. A machine on 12 is a dead end twice over.If the machine is below 13, stop and upgrade macOS before anything else. Ask what the machine actually does for its owner first, then check what Apple will serve it:
df -h / # want ~30GB free
softwareupdate --list-full-installers
Take the newest version that list offers and fetch it by exact version number:
softwareupdate --fetch-full-installer --full-installer-version 15.7.9
open "/Applications/Install macOS Sequoia.app"
Jumping several major versions at once is supported, no stepping stones. Budget one to two hours, nearly all of it download. Afterward run xcode-select --install and then brew update before anything else, because a major macOS upgrade leaves the command line tools stale and Homebrew then fails with a confusing SDK error that looks like Homebrew's own fault.
Skip anything the preflight already found.
This machine came up on Sequoia on 2026-08-16. Run these in order and stop at the first one that errors, because a fresh major version leaves the command line tools stale and Homebrew's error for that looks like a Homebrew problem instead of an SDK one.
sw_vers # confirm 15.x
xcode-select --install # GUI dialog; "already installed" is fine
uname -m # x86_64 on this machine, it is Intel
Because it is Intel, Homebrew lives at /usr/local, not /opt/homebrew, and the Apple Silicon node trap further down does not apply here.
xcode-select --install
A GUI dialog appears. Click install, wait it out. If it says already installed, move on.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
At the end it prints two lines to run to put brew on the PATH. Actually run them. The install does not do it for you and every later step fails without it. On Apple Silicon they reference /opt/homebrew, on Intel /usr/local. If you lose them:
# Apple Silicon
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
# Intel
echo 'eval "$(/usr/local/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/usr/local/bin/brew shellenv)"
brew install tmux node git
node --version && tmux -V
Apple Silicon trap, check this before installing Claude. Run file $(command -v node). If it says x86_64 on an arm64 machine, stop. Claude Code's native binary needs real arm64, Rosetta will not carry it, and the install fails with "claude native binary not installed." Fix is to get arm64 node on the PATH ahead of the x64 one, easiest via nvm:
curl -fsSL -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
nvm install --lts && nvm use --lts
file "$(command -v node)" # must say arm64
On an Intel iMac Pro, x86_64 node is correct and there is nothing to fix.
Use the native installer. It needs no Node, no Homebrew, and no sudo, so this stage can run before Stage 01 has finished downloading.
curl -fsSL https://claude.ai/install.sh | bash
export PATH="$HOME/.local/bin:$PATH"
claude --version
The binary lands at ~/.local/bin/claude, owned by the logged-in user, and updates itself in the background from then on.
Do not install this with npm on a machine where Node came from the .pkg installer. Node's package owns /usr/local/lib/node_modules as root, so npm install -g @anthropic-ai/claude-code dies with EACCES: permission denied. The obvious escape, sudo npm install -g, is the one Anthropic's docs explicitly warn against: it leaves root-owned files that break the auto-updater later. The native installer above sidesteps both.
Then start it once by hand, in a normal Terminal window, so the login happens with a human present:
cd ~
claude
It opens a browser to authorize. Sign in as Will, not Jared. Once you get a prompt, type /status to confirm the account and plan, then /exit.
This is the one real decision in the whole setup, so make it deliberately rather than copying Jared's.
| Mode | What happens | Right when |
|---|---|---|
claude | Asks before each new kind of action | Will is sitting at the machine, learning what it does |
claude --dangerously-skip-permissions | Never asks. Required for an unattended agent, because there is nobody there to click yes | The agent has to run while nobody is watching |
Jared's runs with permissions skipped because it takes work from his phone all day. That is a real tradeoff, not a formality: an unattended agent can delete files, spend money through connected accounts, and send messages as whoever it is signed in as.
Decided 2026-08-16: this machine goes unattended from day one. An attended agent cannot answer the phone, and the phone is the entire point of the build, so the permission prompt would only ever fire into an empty room. The commands below are written for that. If you are following this page on a different machine and want the cautious version, drop --dangerously-skip-permissions from Stages 04 and 05 and skip the launchd job entirely, because a monitor that restarts an agent nobody is there to approve does nothing.
tmux keeps the agent alive after you close Terminal or disconnect. Start it:
tmux new-session -d -s claude -c "$HOME" \
"claude --dangerously-skip-permissions --remote-control Case"
--remote-control Case is the flag that makes Stage 07 work. It registers this session so the Claude app on Will's phone can reach it by name. Execution still happens entirely on the iMac Pro. Put it in now so you do not have to restart the session later.
Everyday commands:
tmux attach -t claude # look at it / talk to it
# press ctrl-b then d to leave it running
tmux ls # what sessions exist
tmux kill-session -t claude
Two files. First the monitor loop:
mkdir -p ~/bin ~/Library/Logs
cat > ~/bin/claude-agent.sh <<'EOF'
#!/bin/bash
SESSION="claude"
WORKDIR="$HOME"
LOG="$HOME/Library/Logs/claude-agent.log"
log(){ echo "$(date '+%F %T') $*" >> "$LOG"; }
session_healthy() {
tmux has-session -t "$SESSION" 2>/dev/null || return 1
pane_pid=$(tmux list-panes -t "$SESSION" -F '#{pane_pid}' 2>/dev/null | head -1)
[ -n "$pane_pid" ] || return 1
ps -p "$pane_pid" -o args= 2>/dev/null | grep -q 'claude' || return 1
return 0
}
# A cold start stops at two full-screen prompts: the folder trust gate and the
# bypass-permissions warning. Nobody is here to answer them, so answer them.
accept_prompts() {
for i in $(seq 1 20); do
sleep 3
pane=$(tmux capture-pane -t "$SESSION" -p -S -60 2>/dev/null)
if [[ "$pane" == *"bypass permissions on"* ]]; then
log "session ready"; return 0
elif [[ "$pane" == *"Bypass Permissions mode"* ]]; then
tmux send-keys -t "$SESSION" "2" Enter; log "accepted bypass warning"
elif [[ "$pane" == *"project you created or one you trust"* ]]; then
tmux send-keys -t "$SESSION" "1" Enter; log "accepted trust prompt"
fi
done
log "WARNING: prompts not cleared after 60s"
}
# A live process is not the same as a usable agent. If the ready footer is
# missing -- prompt copy changed upstream, session wedged -- the process is
# still alive and healthy-looking, so check for readiness separately.
notready=0
while true; do
if ! session_healthy; then
log "session down, starting"
tmux kill-session -t "$SESSION" 2>/dev/null
tmux new-session -d -s "$SESSION" -c "$WORKDIR" \
"claude --dangerously-skip-permissions --remote-control Case"
accept_prompts
notready=0
else
pane=$(tmux capture-pane -t "$SESSION" -p -S -60 2>/dev/null)
if [[ "$pane" == *"bypass permissions on"* ]]; then
notready=0
else
notready=$((notready + 1))
log "WARNING: session alive but not ready (${notready})"
[ "$notready" -eq 4 ] && accept_prompts
if [ "$notready" -ge 8 ]; then
log "ERROR: never became ready, restarting the session"
tmux kill-session -t "$SESSION" 2>/dev/null
notready=0
fi
fi
fi
sleep 30
done
EOF
chmod +x ~/bin/claude-agent.sh
Why accept_prompts exists. Claude Code opens a cold session with two full-screen gates: "is this a folder you trust," and the bypass-permissions warning, whose default is No, exit. A monitor that only starts the process leaves it parked on those prompts forever, and everything above it looks healthy: launchd says running, tmux says the session exists, the pane pid is a real claude. The phone just never sees the machine. Caught on the first real reboot, 2026-08-16.
Then the launchd job that runs it at login and restarts it if it dies:
cat > ~/Library/LaunchAgents/co.casestudy.claude-agent.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>co.casestudy.claude-agent</string>
<key>ProgramArguments</key>
<array><string>$HOME/bin/claude-agent.sh</string></array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>EnvironmentVariables</key>
<dict>
<key>PATH</key><string>$HOME/.local/bin:$(dirname "$(command -v brew)"):$HOME/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<key>HOME</key><string>$HOME</string>
</dict>
<key>StandardOutPath</key><string>$HOME/Library/Logs/claude-agent.out</string>
<key>StandardErrorPath</key><string>$HOME/Library/Logs/claude-agent.err</string>
</dict>
</plist>
EOF
launchctl unload ~/Library/LaunchAgents/co.casestudy.claude-agent.plist 2>/dev/null
launchctl load ~/Library/LaunchAgents/co.casestudy.claude-agent.plist
The gotcha that has bitten this exact setup before. launchd hands a job a bare environment, not your login shell's. If PATH is not spelled out in the plist, the script cannot find tmux or claude and the job fails silently while looking loaded. $HOME/.local/bin has to be in there, because that is where the native installer from Stage 02 puts claude and nothing else on the default path points at it. The EnvironmentVariables block above is not optional. Verify it actually took:
sleep 40; tmux ls; tail -5 ~/Library/Logs/claude-agent.log
sudo pmset -a sleep 0 disksleep 0 womp 1
pmset -g | grep -E "^ *(sleep|disksleep|womp)"
Display sleep is fine and saves the panel. Machine sleep is what kills the agent. womp 1 lets it wake on network.
An agent with no context is a chatbot. Two files make it Will's:
mkdir -p ~/.claude
cat > ~/CLAUDE.md <<'EOF'
# Context
William Douglas. Case Study Coffee Lounge, 4802 N 16th St, Phoenix.
Also runs William Douglas Co (woodworking, visual art, consulting) and a
clothing line under the Case Study name.
## How to work with me
- Blunt and useful. Skip the preamble.
- Act, then report what you did. Do not ask permission for small things.
- If something will not work, say so and why.
- Name the tradeoff when there is one.
## Standing context
- Wood allergy means no sawdust-side production work. Operations, curation,
design direction, and content instead.
- The clothing line is deliberately separate from the cafe brand identity.
EOF
Have Will rewrite that in his own words on day one. The file is loaded into every conversation, so it is the highest-leverage thing on the machine. Jared's is about 200 lines and took months to earn.
No terminal on the phone. No SSH client, no VPN app, no typing tmux commands with his thumbs. He opens the Claude app and his iMac Pro is in the list.
The --remote-control Case flag from Stage 04 is the whole mechanism. It registers the running tmux session with Anthropic so the phone can find it. The work still runs on the iMac Pro, using Will's files, his shop network, his printer. The phone is a window, not the machine.
Nothing to install on the Mac beyond what Stage 04 already did. This works from anywhere, on cell, without touching Case Study's network settings.
Jared's box also answers iMessage, through a relay that watches the Messages database, drops inbound texts into the tmux pane, and sends replies back out. It is roughly a thousand lines of custom code and it is a separate build, not a today job. Worth knowing that the two channels stack: iMessage and the Claude app both feed the same single agent, they do not compete.
Two things to settle before that gets built:
tmux ls # claude: 1 windows ...
tail -3 ~/Library/Logs/claude-agent.log # a start line, no error loop
pmset -g | grep " sleep" # sleep 0
claude --version # a version number
Then reboot the iMac Pro and check tmux ls again after it comes back up. If the session is there without anyone touching it, the setup is real.
Not doing today, on purpose
The memory system, the hooks, the cron jobs, the daily brief, MCP servers for his calendar and email. All of that is worth having and none of it matters until the base agent has been alive for a week and Will knows what he actually wants from it. Jared's box accumulated that over months, one problem at a time, and copying the end state would hand Will a machine he cannot debug.