Asia/Shanghai
April 28, 2026

Productionizing OpenCode: Server-Attach Mode, Robust Boot Scripts, Profile Switching

把 OpenCode 改成 Server-Attach 模式:启动脚本 / Alias / Profile 切换的产品化方案

Mingjian Shao
Productionizing OpenCode: Server-Attach Mode, Robust Boot Scripts, Profile Switching
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.
Out of the box, you launch OpenCode by typing opencode in a project directory. It starts a fresh process every time. Cold starts take 3–5 seconds because of SQLite migrations, plugin load, and MCP server boot. Quit the terminal and the process dies. Open a second terminal and you get a second copy fighting over the same database. For a casual user this is fine. For someone using OpenCode as a primary work environment it's death by a thousand paper cuts.The fix is the serve + attach architecture that's already shipping in OpenCode but isn't surfaced as the default workflow. One persistent server holds the database connection, plugins, and MCP servers. Many lightweight attach clients connect over HTTP on port 4096. Cold start drops to ~100ms. State is unified.
Three layers, three responsibilities:
  • 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.
Profile switching (omoc-switch) is a fourth, orthogonal layer: it just swaps the active config file and asks you to restart. We'll cover it last.~/Library/LaunchAgents/ai.opencode.server.plist (XML prologue and DOCTYPE omitted for clarity — keep them when you actually write the file):
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>
Three things deserve highlighting:
  • KeepAlive=true. If the server dies for any reason — OOM kill, a panic, a kill -9launchd 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.
Load it once: launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/ai.opencode.server.plist. After that, it auto-starts on every login.The plist points at ~/.bin/opencode-server, not at the OpenCode binary. Why a wrapper? Because the boot environment needs more than env vars — it needs an unlock step, a cd, and proxy-stripping logic that's awkward to express in plist XML.
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 "$@"
Three production patterns:
  • 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.
These are your daily drivers. Three commands, all idempotent, all fast.
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"
What makes this robust:
  • 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.
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"
oc-restart is just oc-stop followed by the start half of oc-attach. Same _oc_wait helper, same explicit-failure pattern. The reason both exist: restart is what you reach for after a config change; stop is what you reach for when you want to reclaim RAM before a heavy build.Different days demand different model stacks. My setup has two:
  • 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.
Each profile is a complete oh-my-openagent.json config file kept side-by-side:
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
Switching is just a cp plus a hint to restart:
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
Why a copy instead of a symlink? Two reasons. First, OpenCode's plugin loader sometimes follows symlinks in unexpected ways during hot reload. Second, copying gives you a clean diff target so status can answer "what am I running right now?" with one diff -q. Boring beats clever.This is what the user actually types. Defined in ~/.zshrc:
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"'
The key principle: every script has both a verb-style alias (oc-attach) and a one-letter alias (o). The verb-style is for scripts and documentation; the one-letter is for muscle memory. After two weeks of use, you stop thinking about it — o is just the gateway to your AI.
  • 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.
OpenCode itself still has a single global session pool keyed by working directory — there's no per-project session isolation at the storage layer (everything lives in ~/.local/share/opencode/storage/session/global/ regardless of cwd). Server-attach mode doesn't change that; it just makes the connection to that pool fast. If you need real per-project isolation, you'd need to wrap the binary with a per-project XDG_DATA_HOME. That's a different post.Tested this setup on a fresh Mac that wasn't mine. The scripts above work for me but had four rough edges that bit a clean install. Patching the post rather than the local scripts (mine have been stable for months and aren't worth the risk of touching). If you're following this guide on a new machine, apply these:1. oc-attach fallback order is backwards on a clean restart cycleThe current oc-attach calls launchctl kickstart first and falls back to bootstrap. That works only when the service is already loaded. After oc-stop (which calls bootout), the service definition is gone — kickstart then returns "service not found" with no further effect. Swap the order:
Bash
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \
    || launchctl kickstart "gui/$(id -u)/${LABEL}" 2>/dev/null \
    || true
bootstrap is idempotent-on-load (silently bails if already loaded), kickstart then handles the "loaded but dead" case. This order covers all three states: clean / already-loaded-but-dead / freshly-bootout'd.2. oc-restart must bootstrap after oc-stop, not kickstartSame root cause. oc-stop calls launchctl bootout, which removes the service definition. oc-restart then needs to re-load the plist explicitly:
Bash
oc-stop || exit 1
launchctl bootstrap "gui/$(id -u)" "$PLIST"
The version above accidentally works on my machine because the existing daemon was bootstrapped at login and never fully removed — but on a clean install the second orestart fails silently.3. oc-stop's 4-second wait is too tight under DB pressureWhen opencode.db is multi-GB, draining writes on shutdown can take longer than 4s. Bump the timeout to ~10s and add a kill -9 last resort:
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
4. Single source of truth for LABEL and PLISTThe label ai.opencode.server is hardcoded across the plist filename, plist <key>Label</key>, oc-attach, oc-stop, and oc-restart — five places. Pull it into one variable per script:
Bash
LABEL="ai.opencode.server"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
Cosmetic, but if you ever want a second profile (e.g. ai.opencode.server-staging), this is the line you flip.5. Session storage clarification (not a bug, an FAQ)Several readers asked: "if I run opencode from different project directories, does each project get its own session pool?" No. OpenCode writes all sessions to ~/.local/share/opencode/storage/session/global/ regardless of cwd — they're tagged with a directory field for filtering, but stored in one flat pool. The server-attach setup doesn't change this; it just makes the connection to that pool fast. If you want true per-project isolation, you'd have to wrap the binary with a per-project XDG_DATA_HOME. Out of scope for this post.Credit: spotted by Claude Opus while debugging a fresh install on a friend's machine.If you use an AI coding assistant like OpenCode, paste this prompt and let it set up the whole stack on your Mac:
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 切换器。这篇是我用了几个月、稳定下来的全套方案。
OpenCode 默认的玩法是:在项目目录里敲 opencode,每次都新起一个进程。冷启动 3-5 秒,因为要跑 SQLite 迁移、加载插件、启 MCP server。关掉终端就死。开第二个终端就有第二份在抢同一个数据库。轻度用户没问题,重度用户每天被纸割流血。正解是 OpenCode 已经实现但没当默认推的 serve + attach 架构。一个常驻 server 持有数据库连接、插件、MCP servers;多个轻量 attach 客户端通过 4096 端口的 HTTP 连进来。冷启动从 3-5 秒砍到 ~100ms,状态全局唯一。
三层、各管一摊:
  • LaunchAgent — 让 server 在崩溃和重启后还活着。launchd 干脏活,我们只在 plist 里声明意图。
  • Shell 包装层 (opencode-server) — LaunchAgent 调的是这个包装脚本,不是 OpenCode 二进制。是塞环境变量、Bitwarden 解锁、设置工作目录的最佳位置。
  • Attach 脚本 (oc-attach / oc-stop / oc-restart) — 你每天敲的命令。幂等、防御式、走快速路径。
Profile 切换 (omoc-switch) 是第四层独立的事:换掉当前 active 的 config 文件,提示你重启即可。最后讲。~/Library/LaunchAgents/ai.opencode.server.plist(XML 头和 DOCTYPE 写实际文件的时候要带上,这里为了行文清爽省略了):
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 本地回环必须绕过代理,不然每个工具调用都神秘超时。
只需 bootstrap 一次:launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/ai.opencode.server.plist。之后每次开机自动起。plist 里指的是 ~/.bin/opencode-server 这个包装脚本,不是 OpenCode 二进制本身。为啥要包一层? 因为启动环境要做的事比纯环境变量多——要 unlock、要 cd、要剥代理变量,这些在 plist XML 里写起来非常别扭。
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 "$@"
三个生产级的 pattern:
  • 末尾用 exec。包装脚本把自己替换成真正的 server 进程——没有 orphan shell、信号传递干净、pgrep -f "opencode.* serve" 找到的就是 server 本体。
  • 副作用日志单独走一份。Bitwarden unlock 自己一份 log,stderr 不污染主 server log。半夜出问题时你会感谢现在的自己。
  • Hostname 绑 0.0.0.0。如果你想从 Tailscale 或局域网另一台机器 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。不静默卡死
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 已停"
oc-restart 就是 oc-stop + oc-attach 后半段。同样的 _oc_wait、同样的显式失败模式。为啥两个都要: restart 是改完配置后摸的;stop 是 build 大项目前想腾内存时摸的。不同时段需要不同的模型组合。我有两个:
  • sub2api — Opus 总指挥 + GPT 干代码重活。日常主力。
  • qwen — Qwen 3.6 + GLM-5 + Kimi。sub2api 限速或者我心疼额度时切过去。
每个 profile 是一份完整的 oh-my-openagent.json,并排放着:
Text
~/.config/opencode/
├── oh-my-openagent.json              ← 当前 active 的(OpenCode 实际读的)
├── oh-my-openagent-sub2api.json      ← profile A
└── oh-my-openagent-qwen.json         ← profile B
切换就是一个 cp 加一句"重启提醒":
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
为啥用 cp 而不是 symlink? 两个原因。一是 OpenCode 的插件加载器在热重载时偶尔会用奇怪的方式跟随 symlink。二是 copy 之后 status 可以靠 diff -q 一行回答"现在跑的是哪个"。Boring 比 clever 好用户实际敲的是这些。~/.zshrc 里:
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"'
核心原则:每个脚本既给一个动词式 alias(oc-attach),也给一个一字母 alias(o)。动词式给文档和脚本用,一字母给肌肉记忆用。两周后你就不用想了——o 就是通往 AI 的那扇门。
  • 秒级 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 状态,互不打架。
OpenCode 自己有个全局 session 池,按 cwd 字段区分,但不在存储层做 per-project 隔离——所有 session 文件都在 ~/.local/share/opencode/storage/session/global/ 一锅烩,跟 cwd 无关。Server-attach 模式不改这个,它只是让你连到这个池的过程变快。如果你真要 per-project 物理隔离,得给二进制套个 per-project 的 XDG_DATA_HOME 包装。那是另一篇博客的事了。在一台不是我的全新 Mac 上跑了一遍这套配置。我本地的脚本稳定跑了几个月没问题,但 clean install 暴露了 4 个粗糙的边角。改博客不改本地脚本——本地这套半年没出事,没必要为了"洁癖"去动它。但如果你在新机器上照着这篇装,下面这些必须打补丁。1. oc-attach 启动 fallback 顺序在 clean restart 时是反的现在 oc-attachlaunchctl kickstart,失败再 bootstrap。这个顺序只有在 service 已经 loaded 的时候才工作oc-stop 调了 bootout,service 定义就没了——这时候 kickstart 直接返回 "service not found",啥也没干。两个调换顺序:
Bash
launchctl bootstrap "gui/$(id -u)" "$PLIST" 2>/dev/null \
    || launchctl kickstart "gui/$(id -u)/${LABEL}" 2>/dev/null \
    || true
bootstrap 是幂等的(已加载会静默 bail),kickstart 处理"已加载但进程死了"的情况。这个顺序覆盖三种状态:干净 / loaded-but-dead / 刚 bootout。2. oc-restartoc-stop 之后必须 bootstrap,不能 kickstart同一个坑。oc-stop 里的 launchctl bootout 把 service 定义抹掉了,oc-restart 必须显式重新加载 plist:
Bash
oc-stop || exit 1
launchctl bootstrap "gui/$(id -u)" "$PLIST"
我本地这版能跑是因为 daemon 是登录时 bootstrap 进去的、一直没被完全移除——但 clean install 跑第二次 orestart 就会静默失败。3. oc-stop 等 4 秒不够opencode.db 几个 GB 的时候,关停时刷盘可能超过 4 秒。超时改成 10 秒,最后兜底 kill -9
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
4. LABELPLIST 应该是单一变量label ai.opencode.server 现在硬编码在 plist 文件名、plist 里的 <key>Label</key>oc-attachoc-stopoc-restart ——五个地方。每个脚本头部抽出一个变量:
Bash
LABEL="ai.opencode.server"
PLIST="$HOME/Library/LaunchAgents/${LABEL}.plist"
外观问题,但你将来要是搞第二个 profile(比如 ai.opencode.server-staging),就这一行要改。5. Session 存储澄清(不是 bug,是 FAQ)有读者问:"我从不同项目目录跑 opencode,session 是按项目分开存的吗?"不是。 OpenCode 把所有 session 都写在 ~/.local/share/opencode/storage/session/global/,跟 cwd 无关——只是每个 session JSON 里带一个 directory 字段用来在列表里过滤。Server-attach 不改这个,只是让你连到这个池的过程变快。如果真想做物理 per-project 隔离,得给二进制套个 per-project 的 XDG_DATA_HOME 包装,那是另一篇博客的事了。致谢:Claude Opus 在朋友机器上 debug clean install 的时候找出来的。如果你用 OpenCode 这类 AI 编程助手,把下面这段 prompt 丢给它,它会直接帮你把整套部署起来:
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

For AI Agents

This post ships a machine-readable execution plan. Paste the prompt into OpenCode / Claude Code / Cursor / Codex CLI (any agent with shell + file write tools) and it will reproduce the setup with pre-flight checks. Chat-only LLMs without tools should refuse, per the embedded SAFETY clause.
Share this post:
Enjoy this post? Subscribe via RSS: English | 中文