If you live inside OpenCode all day, the default opencode command is not enough. You need a daemon, an attach client, and a profile switcher that survives crashes, reboots, and provider outages. Here's the full setup I run.
Why standalone mode breaks down
Architecture at a glance
- LaunchAgent — keeps the server alive across crashes and reboots. macOS' launchd does the heavy lifting; we just declare intent in a plist.
- Shell wrapper (opencode-server) — the LaunchAgent calls this instead of the binary directly. It's the right place to set up environment (proxy stripping, Bitwarden unlock, working directory) before exec'ing the actual server.
- Attach scripts (oc-attach, oc-stop, oc-restart) — your daily commands. Idempotent, defensive, fast-path optimized.
Layer 1: The LaunchAgent
Xml
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.opencode.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.bin/opencode-server</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/you/Documents/sync</string>
<key>EnvironmentVariables</key>
<dict>
<key>NO_PROXY</key>
<string>10.0.0.0/8,127.0.0.1,localhost</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/Users/you/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/you/.local/share/opencode/log/server.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/.local/share/opencode/log/server-error.log</string>
</dict>
</plist>
- KeepAlive=true. If the server dies for any reason — OOM kill, a panic, a kill -9 — launchd restarts it. This is the single most important property of the whole setup.
- PATH is explicit. LaunchAgents do not inherit your shell's PATH. If you don't list Homebrew here, the MCP servers that shell out to npx, bw, or uv will silently fail.
- NO_PROXY covers RFC1918. On a Mac with Surge / Clash routing system traffic, OpenCode's localhost calls must bypass the proxy or you'll see weird timeouts on every tool call.
Layer 2: The shell wrapper
Bash
#!/bin/bash
# ~/.bin/opencode-server
# Strip inherited proxy vars — Surge handles routing at the system layer.
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy NODE_OPTIONS
export NO_PROXY="10.0.0.0/8,127.0.0.1,localhost"
cd "$HOME/Documents/sync"
# ── Bitwarden auto-unlock ──────────────────────────────────
# Read master password from macOS Keychain → unlock vault → export
# BW_SESSION so the Bitwarden-using MCPs and skills inherit it.
BW_LOG="$HOME/.local/share/opencode/log/bw-unlock.log"
mkdir -p "$(dirname "$BW_LOG")"
{
echo "[$(date -Iseconds)] opencode-server starting, attempting bw unlock"
if BW_PW=$(security find-generic-password -a "bitwarden-cli" -s "bw-master-password" -w 2>/dev/null); then
if BW_SESSION_VAL=$(/opt/homebrew/bin/bw unlock "$BW_PW" --raw 2>/dev/null); then
export BW_SESSION="$BW_SESSION_VAL"
echo "[$(date -Iseconds)] ✓ bw unlocked (len=${#BW_SESSION})"
else
echo "[$(date -Iseconds)] ✗ bw unlock failed"
fi
unset BW_PW
fi
} >> "$BW_LOG" 2>&1
exec "$HOME/.opencode/bin/opencode" serve --port 4096 --hostname 0.0.0.0 "$@"
- exec at the end. The wrapper replaces itself with the real server process — no orphan shell, clean signal handling, pgrep -f "opencode.* serve" finds the actual server.
- Side-effects logged to a separate file. The Bitwarden unlock has its own log so its stderr doesn't pollute server logs. Future-you will thank present-you when something goes wrong at 11pm.
- Hostname 0.0.0.0. Letting LAN reach the server is required if you want oc-attach from another device on Tailscale or your home subnet to work.
Layer 3: The attach scripts
oc-attach — the workhorse
Bash
#!/bin/bash
# ~/.bin/oc-attach
SYNC_ROOT="${SYNC_ROOT:-$HOME/Documents/sync}"
PLIST="$HOME/Library/LaunchAgents/ai.opencode.server.plist"
_oc_wait() {
local max=$1 i=0
while [ $i -lt $max ]; do
if NO_PROXY=localhost,127.0.0.1 \
curl -sf -m 1 http://127.0.0.1:4096 >/dev/null 2>&1; then
return 0
fi
sleep 0.5
i=$((i + 1))
done
return 1
}
# Fast path: process alive → skip HTTP check, just attach.
if pgrep -qf 'opencode.* serve'; then
NO_PROXY=localhost,127.0.0.1 exec "$HOME/.opencode/bin/opencode" \
attach http://127.0.0.1:4096 --dir "$SYNC_ROOT"
fi
# Server not running → kickstart via launchd.
echo "Server not running. Starting..."
launchctl kickstart "gui/$(id -u)/ai.opencode.server" 2>/dev/null \
|| launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null
if ! _oc_wait 20; then
echo "Start failed. Check: tail -f ~/.local/share/opencode/log/server-error.log"
exit 1
fi
NO_PROXY=localhost,127.0.0.1 exec "$HOME/.opencode/bin/opencode" \
attach http://127.0.0.1:4096 --dir "$SYNC_ROOT"
- Fast path first. 99% of the time the server is already up. pgrep returns in <10ms. We skip the HTTP probe entirely on the happy path.
- Self-healing. If the server is somehow gone, we try launchctl kickstart (cheap restart) before falling back to bootstrap (full re-load).
- Bounded wait with explicit failure. 10 seconds, then we tell you exactly which log file to check. No silent hang.
oc-stop and oc-restart — symmetric and forgiving
Bash
#!/bin/bash
# ~/.bin/oc-stop
PLIST="$HOME/Library/LaunchAgents/ai.opencode.server.plist"
launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null
pkill -f "opencode.* serve" 2>/dev/null # belt and suspenders
# Wait up to 4s for port to release.
i=0
while [ $i -lt 20 ]; do
lsof -iTCP:4096 -sTCP:LISTEN >/dev/null 2>&1 || break
sleep 0.2
i=$((i + 1))
done
echo "OpenCode stopped"
Layer 4: Profile switching
- sub2api — Opus orchestration + GPT for code-heavy subtasks. Production daily driver.
- qwen — Qwen 3.6 + GLM-5 + Kimi. Used when sub2api is rate-limited or when I'm on a meter.
Text
~/.config/opencode/
├── oh-my-openagent.json ← active config (the one OpenCode reads)
├── oh-my-openagent-sub2api.json ← profile A
└── oh-my-openagent-qwen.json ← profile B
Bash
#!/bin/bash
# ~/.bin/omoc-switch
CONFIG_DIR="$HOME/.config/opencode"
ACTIVE="$CONFIG_DIR/oh-my-openagent.json"
case "$1" in
sub2api)
cp "$CONFIG_DIR/oh-my-openagent-sub2api.json" "$ACTIVE"
echo "✓ switched to sub2api — run orestart to apply"
;;
qwen)
cp "$CONFIG_DIR/oh-my-openagent-qwen.json" "$ACTIVE"
echo "✓ switched to qwen — run orestart to apply"
;;
status)
# diff against each candidate to detect current profile
for p in sub2api qwen; do
f="$CONFIG_DIR/oh-my-openagent-$p.json"
if diff -q "$ACTIVE" "$f" >/dev/null 2>&1; then
echo "→ active: $p"
exit 0
fi
done
echo "→ active: custom (no match)"
;;
*)
echo "Usage: omoc-switch {sub2api|qwen|status}"
;;
esac
The alias surface
Bash
# Lifecycle
alias o='oc-attach'
alias ostop='oc-stop'
alias orestart='oc-restart'
# Diagnostics
alias oweb='open http://localhost:4096'
alias olog='tail -f ~/.local/share/opencode/log/server.log'
alias oerr='tail -f ~/.local/share/opencode/log/server-error.log'
alias ohealth='launchctl list | grep opencode; pgrep -fl "opencode serve"; lsof -iTCP:4096 -sTCP:LISTEN'
# Profile switcher
alias osub='omoc-switch sub2api'
alias oqwen='omoc-switch qwen'
alias ostatus='omoc-switch status'
# Hygiene
alias oclean='pkill -f "opencode attach"'
alias oupdate='cd ~/.local/share/opencode && bun update oh-my-openagent && echo "Run: orestart"'
What this gives you
- Instant attach. ~100ms from terminal open to prompt-ready. Cold-start cost amortized into a single login-time launchd boot.
- Auto-recovery. Server crashes are invisible — launchd restarts within 1s, your next o reconnects to the new instance.
- Profile agility. Switching between Opus and Qwen takes one keystroke and one restart, ~3s total. No editing JSON, no re-authing.
- Observability. Three log files (server.log, server-error.log, bw-unlock.log) plus ohealth give you a complete view in 5 seconds.
- Multi-terminal sanity. Run o in five tabs; they all attach to the same server, share the same database state, and don't fight each other.
What it doesn't fix
Update 2026-04-28 — Field Notes from Installing on Someone Else's Mac
Bash
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \
|| launchctl kickstart "gui/$(id -u)/${LABEL}" 2>/dev/null \
|| true
Bash
oc-stop || exit 1
launchctl bootstrap "gui/$(id -u)" "$PLIST"
Bash
MAX_WAIT=10
# ...wait loop...
if lsof -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "Port ${PORT} still busy after ${MAX_WAIT}s — force-killing"
lsof -iTCP:"${PORT}" -sTCP:LISTEN -t 2>/dev/null | xargs -r kill -9 2>/dev/null
pkill -9 -f "opencode.* serve" 2>/dev/null
fi
Bash
LABEL="ai.opencode.server"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
Let AI Do It
Bash
I want to convert my OpenCode install into a server-attach setup with LaunchAgent,
boot scripts, alias surface, and profile switcher.
Reference: https://mjshao.fun/blog/opencode-server-attach-productionized
My setup:
- OpenCode binary path: [ask me, default ~/.opencode/bin/opencode]
- Working directory the server should bind to: [ask me]
- Profile names I want to support: [ask me, e.g. sub2api, qwen]
- Whether to wire Bitwarden auto-unlock: [ask me, default yes]
Steps:
1. Create ~/.bin/opencode-server wrapper (proxy stripping + cd + exec serve)
2. Create ~/Library/LaunchAgents/ai.opencode.server.plist with KeepAlive=true
3. Create ~/.bin/oc-attach with fast-path pgrep + launchctl kickstart fallback
4. Create ~/.bin/oc-stop and ~/.bin/oc-restart (symmetric, idempotent)
5. Create ~/.bin/omoc-switch with cp-based profile swap and diff-based status
6. Append alias block to ~/.zshrc (o / ostop / orestart / osub / oqwen / ostatus)
7. launchctl bootstrap the plist and verify with curl 127.0.0.1:4096
8. Confirm `o` attaches in less than 200ms
如果你每天泡在 OpenCode 里,默认的 opencode 命令是顶不住的。你需要一个常驻 server、一个 attach 客户端、一套能扛崩溃和断网的 profile 切换器。这篇是我用了几个月、稳定下来的全套方案。
为什么默认模式扛不住
整体架构
- LaunchAgent — 让 server 在崩溃和重启后还活着。launchd 干脏活,我们只在 plist 里声明意图。
- Shell 包装层 (opencode-server) — LaunchAgent 调的是这个包装脚本,不是 OpenCode 二进制。是塞环境变量、Bitwarden 解锁、设置工作目录的最佳位置。
- Attach 脚本 (oc-attach / oc-stop / oc-restart) — 你每天敲的命令。幂等、防御式、走快速路径。
第一层:LaunchAgent
Xml
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.opencode.server</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.bin/opencode-server</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>/Users/you/Documents/sync</string>
<key>EnvironmentVariables</key>
<dict>
<key>NO_PROXY</key>
<string>10.0.0.0/8,127.0.0.1,localhost</string>
<key>PATH</key>
<string>/opt/homebrew/bin:/Users/you/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
<key>StandardOutPath</key>
<string>/Users/you/.local/share/opencode/log/server.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/.local/share/opencode/log/server-error.log</string>
</dict>
</plist>
- KeepAlive=true。无论 server 怎么死——OOM、panic、被你 kill -9——launchd 都会立刻拉起来。整个方案最关键的一条属性。
- PATH 必须显式写。LaunchAgent 不继承 shell 的 PATH。如果你不把 Homebrew 路径写进来,那些要 shell out 调 npx / bw / uv 的 MCP server 全部静默挂掉。
- NO_PROXY 要覆盖 RFC1918。Mac 上跑了 Surge / Clash 接管系统流量的话,OpenCode 本地回环必须绕过代理,不然每个工具调用都神秘超时。
第二层:Shell 包装
Bash
#!/bin/bash
# ~/.bin/opencode-server
# 剥掉继承来的代理变量 — Surge 在系统层接管路由。
unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy NODE_OPTIONS
export NO_PROXY="10.0.0.0/8,127.0.0.1,localhost"
cd "$HOME/Documents/sync"
# ── Bitwarden 自动解锁 ────────────────────────────────────
# 从 macOS Keychain 读主密码 → 解锁 vault → export BW_SESSION
# 让用 Bitwarden 的 MCP / skill 都能继承到。
BW_LOG="$HOME/.local/share/opencode/log/bw-unlock.log"
mkdir -p "$(dirname "$BW_LOG")"
{
echo "[$(date -Iseconds)] opencode-server 启动,尝试 bw unlock"
if BW_PW=$(security find-generic-password -a "bitwarden-cli" -s "bw-master-password" -w 2>/dev/null); then
if BW_SESSION_VAL=$(/opt/homebrew/bin/bw unlock "$BW_PW" --raw 2>/dev/null); then
export BW_SESSION="$BW_SESSION_VAL"
echo "[$(date -Iseconds)] ✓ bw 已解锁 (len=${#BW_SESSION})"
else
echo "[$(date -Iseconds)] ✗ bw unlock 失败"
fi
unset BW_PW
fi
} >> "$BW_LOG" 2>&1
exec "$HOME/.opencode/bin/opencode" serve --port 4096 --hostname 0.0.0.0 "$@"
- 末尾用 exec。包装脚本把自己替换成真正的 server 进程——没有 orphan shell、信号传递干净、pgrep -f "opencode.* serve" 找到的就是 server 本体。
- 副作用日志单独走一份。Bitwarden unlock 自己一份 log,stderr 不污染主 server log。半夜出问题时你会感谢现在的自己。
- Hostname 绑 0.0.0.0。如果你想从 Tailscale 或局域网另一台机器 attach 进来,这个必须开。
第三层:Attach 脚本
oc-attach — 干活的主力
Bash
#!/bin/bash
# ~/.bin/oc-attach
SYNC_ROOT="${SYNC_ROOT:-$HOME/Documents/sync}"
PLIST="$HOME/Library/LaunchAgents/ai.opencode.server.plist"
_oc_wait() {
local max=$1 i=0
while [ $i -lt $max ]; do
if NO_PROXY=localhost,127.0.0.1 \
curl -sf -m 1 http://127.0.0.1:4096 >/dev/null 2>&1; then
return 0
fi
sleep 0.5
i=$((i + 1))
done
return 1
}
# 快速路径:进程存活 → 跳过 HTTP 探测,直接 attach
if pgrep -qf 'opencode.* serve'; then
NO_PROXY=localhost,127.0.0.1 exec "$HOME/.opencode/bin/opencode" \
attach http://127.0.0.1:4096 --dir "$SYNC_ROOT"
fi
# Server 没起 → launchd kickstart
echo "Server 没起,正在启动..."
launchctl kickstart "gui/$(id -u)/ai.opencode.server" 2>/dev/null \
|| launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null
if ! _oc_wait 20; then
echo "启动失败。看日志:tail -f ~/.local/share/opencode/log/server-error.log"
exit 1
fi
NO_PROXY=localhost,127.0.0.1 exec "$HOME/.opencode/bin/opencode" \
attach http://127.0.0.1:4096 --dir "$SYNC_ROOT"
- 快速路径优先。99% 情况 server 已经在跑。pgrep 在 <10ms 内返回,整个 happy path 直接跳过 HTTP 探测。
- 自愈。如果 server 不知道为啥没了,先试 launchctl kickstart(轻量重启),不行再 bootstrap(重新加载)。
- 有界等待 + 显式失败。10 秒等不到就告诉你看哪个 log。不静默卡死。
oc-stop 和 oc-restart — 对称、宽容
Bash
#!/bin/bash
# ~/.bin/oc-stop
PLIST="$HOME/Library/LaunchAgents/ai.opencode.server.plist"
launchctl bootout "gui/$(id -u)" "$PLIST" 2>/dev/null
pkill -f "opencode.* serve" 2>/dev/null # 双保险
# 等端口最多释放 4 秒
i=0
while [ $i -lt 20 ]; do
lsof -iTCP:4096 -sTCP:LISTEN >/dev/null 2>&1 || break
sleep 0.2
i=$((i + 1))
done
echo "OpenCode 已停"
第四层:Profile 切换
- sub2api — Opus 总指挥 + GPT 干代码重活。日常主力。
- qwen — Qwen 3.6 + GLM-5 + Kimi。sub2api 限速或者我心疼额度时切过去。
Text
~/.config/opencode/
├── oh-my-openagent.json ← 当前 active 的(OpenCode 实际读的)
├── oh-my-openagent-sub2api.json ← profile A
└── oh-my-openagent-qwen.json ← profile B
Bash
#!/bin/bash
# ~/.bin/omoc-switch
CONFIG_DIR="$HOME/.config/opencode"
ACTIVE="$CONFIG_DIR/oh-my-openagent.json"
case "$1" in
sub2api)
cp "$CONFIG_DIR/oh-my-openagent-sub2api.json" "$ACTIVE"
echo "✓ 切到 sub2api — 跑 orestart 生效"
;;
qwen)
cp "$CONFIG_DIR/oh-my-openagent-qwen.json" "$ACTIVE"
echo "✓ 切到 qwen — 跑 orestart 生效"
;;
status)
for p in sub2api qwen; do
f="$CONFIG_DIR/oh-my-openagent-$p.json"
if diff -q "$ACTIVE" "$f" >/dev/null 2>&1; then
echo "→ 当前: $p"
exit 0
fi
done
echo "→ 当前: custom(不匹配任何已知 profile)"
;;
*)
echo "用法: omoc-switch {sub2api|qwen|status}"
;;
esac
Alias 表层
Bash
# 生命周期
alias o='oc-attach'
alias ostop='oc-stop'
alias orestart='oc-restart'
# 诊断
alias oweb='open http://localhost:4096'
alias olog='tail -f ~/.local/share/opencode/log/server.log'
alias oerr='tail -f ~/.local/share/opencode/log/server-error.log'
alias ohealth='launchctl list | grep opencode; pgrep -fl "opencode serve"; lsof -iTCP:4096 -sTCP:LISTEN'
# Profile 切换
alias osub='omoc-switch sub2api'
alias oqwen='omoc-switch qwen'
alias ostatus='omoc-switch status'
# 卫生
alias oclean='pkill -f "opencode attach"'
alias oupdate='cd ~/.local/share/opencode && bun update oh-my-openagent && echo "跑 orestart"'
这套配置给你什么
- 秒级 attach。开终端到提示符就绪 ~100ms。冷启动成本被 launchd 在登录时一次性吃掉。
- 自动恢复。Server 崩溃对你不可见——launchd 1 秒内拉起,下一次 o 直接连到新实例。
- Profile 灵活。Opus 和 Qwen 之间一个键 + 一次重启切换,全程 ~3 秒。不用编辑 JSON、不用重新认证。
- 可观测。三份 log(server.log / server-error.log / bw-unlock.log)加 ohealth,5 秒内对系统状态有完整视图。
- 多终端清醒。开五个 tab 都跑 o,全部连同一个 server,共享同一份 db 状态,互不打架。
这套不解决什么
更新 2026-04-28 —— 在别人电脑上踩坑实录
Bash
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \
|| launchctl kickstart "gui/$(id -u)/${LABEL}" 2>/dev/null \
|| true
Bash
oc-stop || exit 1
launchctl bootstrap "gui/$(id -u)" "$PLIST"
Bash
MAX_WAIT=10
# ...wait 循环...
if lsof -iTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
echo "Port ${PORT} still busy after ${MAX_WAIT}s — force-killing"
lsof -iTCP:"${PORT}" -sTCP:LISTEN -t 2>/dev/null | xargs -r kill -9 2>/dev/null
pkill -9 -f "opencode.* serve" 2>/dev/null
fi
Bash
LABEL="ai.opencode.server"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
让 AI 帮你搞定
Bash
我想把 OpenCode 改造成 server-attach 模式,配上 LaunchAgent、启动脚本、alias、profile 切换器。
参考: https://mjshao.fun/blog/opencode-server-attach-productionized
我的环境:
- OpenCode 二进制路径: [问我,默认 ~/.opencode/bin/opencode]
- Server 绑定的工作目录: [问我]
- 需要支持的 profile 名: [问我,比如 sub2api、qwen]
- 是否接 Bitwarden 自动解锁: [问我,默认 yes]
步骤:
1. 写 ~/.bin/opencode-server 包装脚本(剥代理 + cd + exec serve)
2. 写 ~/Library/LaunchAgents/ai.opencode.server.plist,KeepAlive=true
3. 写 ~/.bin/oc-attach(pgrep 快速路径 + launchctl kickstart 兜底)
4. 写 ~/.bin/oc-stop 和 ~/.bin/oc-restart(对称、幂等)
5. 写 ~/.bin/omoc-switch(cp 切 profile + diff 判断当前)
6. 在 ~/.zshrc 追加 alias 块(o / ostop / orestart / osub / oqwen / ostatus)
7. launchctl bootstrap 这个 plist,curl 127.0.0.1:4096 验证通
8. 确认 `o` 在 200ms 内能 attach 上