远程开发好几年了,IDE 和代码都能远程,凭啥 AI 编程还得绑在终端里?折腾了一下 OpenCode + OpenChamber,现在手机、公司、家里打开浏览器就能接着写。

Why
我笔记本性能不差,但工作得用 Windows。Windows 下搞开发有多蛋疼用过的人都知道——换行符、路径、包管理器、Docker,每一样都在磨你耐心。所以好几年前我就在 Homelab 里搭了一台 7×24 的 Rocky Linux 开发机,JetBrains Gateway 连上去跑 IntelliJ + GoLand + PyCharm + WebStorm + RustRover,再挂一个 VS Code Remote,早年也用过 Cursor Remote。所有 IDE 都在服务端,笔记本就是个带屏幕的终端。
公司那边我也有一台类似的个人开发服务器。回家开发公司项目,或者到了公司想接着搞家里的实验,连回去就是同一个 workspace,不用搬代码。
代价就是断网即失业,VPN 焊死在身上。
IDE 和代码早就远程了,AI 编程咋还绑在我本地的终端里?
最早用的 Cursor,配合 Remote SSH 也能远程,但它只是个 IDE 助手,不是 Agent——你没法把浏览器一关让它自己在后台跑。后来换了 Claude Code、Kimi Code,能做 Agent 了,但绑在终端里,远程场景一下两个痛点全暴露了:
手机:路上冒出个想法想给正在跑的项目下个指令,掏出手机 SSH 到服务器敲命令行?别逗了。OpenChamber 有个 iOS App(TestFlight 就能装),连上 VPN 打开就是一个完整的 AI 编程界面——打字说人话,Agent 去干活。它还能推通知,任务跑完或者需要确认的时候手机直接弹,不用时不时掏出来刷新。


跨地点:以前用纯终端 opencode TUI,在家给个人项目开了个 session,到公司想继续搞——得 SSH 回去找 session ID restore。反着来也一样。现在会话状态全在服务端,不管在哪打开浏览器,所有 session 都在那排着,点开继续。跟 JetBrains Gateway 一个体验——连上去就是你的 workspace。
说白了就是把 AI Agent 从"命令行工具"变成了开发机上的一个常驻服务。
How
架构很简单:
手机(iOS App) ──→ VPN ──→ [nginx/HTTPS] ──→ dev 机 :4095
公司电脑 ──→ VPN ──→ [nginx/HTTPS] ──→ 同上
│
OpenChamber (Web UI)
监听 0.0.0.0:4095
│
OPENCODE_HOST=http://localhost:4096
│
OpenCode Server
仅监听 127.0.0.1:4096
OpenChamber 监听所有网口,OpenCode 只绑 127.0.0.1,中间靠 OPENCODE_HOST 环境变量通信。前面套不套 nginx 随意——直接 http://开发机:4095 也能用。我因为 Homelab 本来就有 nginx 在跑,顺手加了 HTTPS,顺便让 iOS App 的推送正常工作(Apple 那边没 TLS 不让推)。
家里和公司各部署一套,浏览器里就是两个独立的 AI 编程环境。
装 OpenCode
curl -fsSL https://opencode.ai/install | bash
二进制落到 ~/.opencode/bin/opencode,加 PATH 就行。我这边版本 v1.18.15。
三个核心插件——websearch(联网搜索)、mem(跨会话记忆)、pty(后台跑长任务)——通过 npm 包加载,需要在配置目录下装一下依赖:
cd ~/.config/opencode
npm init -y
npm install @opencode-ai/plugin@1.18.4
这包的版本跟 opencode 二进制是对应的,别手贱升级。
装 OpenChamber
pnpm add -g @openchamber/web
落到 ~/.local/share/pnpm/bin/openchamber。没 pnpm 的话 npm i -g pnpm 先。
配 systemd
俩 service,前后端分开:
/etc/systemd/system/opencode.service:
[Unit]
Description=OpenCode Server
After=network.target
[Service]
Type=simple
User=milkfish
WorkingDirectory=/home/milkfish
ExecStart=/home/milkfish/.opencode/bin/opencode web --hostname 127.0.0.1
Restart=on-failure
RestartSec=5
Environment="PATH=/home/milkfish/n/bin:/home/milkfish/.local/share/pnpm/bin:/usr/local/bin:/usr/bin:/bin"
[Install]
WantedBy=multi-user.target
/etc/systemd/system/openchamber.service:
[Unit]
Description=OpenChamber Web
After=opencode.service network.target
[Service]
Type=simple
User=milkfish
WorkingDirectory=/home/milkfish
ExecStartPre=-/usr/bin/rm -f /home/milkfish/.config/openchamber/run/openchamber-4095.pid /home/milkfish/.config/openchamber/run/openchamber-4095.json
Environment="OPENCODE_HOST=http://localhost:4096"
Environment="OPENCODE_SKIP_START=true"
Environment="PATH=/home/milkfish/n/bin:/home/milkfish/.local/share/pnpm/bin:/home/milkfish/.opencode/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/home/milkfish/.local/share/pnpm/bin/openchamber --foreground --host 0.0.0.0 --port 4095 --ui-password <你的密码>
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
这里 systemd 有个很坑的地方——不读 .bashrc,PATH 是干净的。所以 Environment="PATH=..." 必须写全。我的 PATH 里有三个关键路径:n/bin(Node 版本管理器)、pnpm/bin(全局包)、.opencode/bin,少一个就报 command not found。
ExecStartPre 那行的作用是启动前清理上次异常退出残留的 PID 文件。最前面的 - 表示"文件不存在也别报错",别漏了。OPENCODE_SKIP_START=true 是让 OpenChamber 别自己再起一个 OpenCode——已经有第一个 service 管了,两个一起跑会冲突。
启动:
systemctl daemon-reload
systemctl enable --now opencode openchamber
浏览器打开 http://你的IP:4095,输密码进去就行。密码建议 openssl rand -hex 16 随机一把——这货有完整的 bash 执行权限,别整个 123456 糊弄自己。

配置文件
~/.config/opencode/opencode.jsonc:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-websearch", "opencode-mem", "opencode-pty"],
"mcp": {
"context7": {
"type": "local",
"command": ["npx", "-y", "@upstash/context7-mcp"]
}
},
"provider": {
"moonshotai-cn": {
"options": { "baseURL": "https://api.moonshot.cn/v1" },
"models": {
"kimi-k2.6": { "options": { "websearch": "always" } }
}
}
},
"permission": {
"bash": {
"go *": "allow",
"npm *": "allow",
"make *": "allow",
"docker *": "allow",
"terraform *": "deny"
}
}
}
这里 provider 那段不是配 OpenCode 用哪个模型干活——干活用的模型启动后 /connect 选就行,内置了 DeepSeek 之类的一堆 provider。这段是给 opencode-websearch 插件用的:它需要一个能联网搜索的模型来做 RAG,我选了 Kimi(api.moonshot.cn 国内直连友好,websearch: "always" 让它每次搜)。

MCP 挂了 context7,让 Agent 能实时查最新文档,不会凭训练数据在那瞎编 API。
权限那块 go/npm/make/docker 自动放行,terraform 自动拒绝(怕它乱动基础设施),其余弹窗确认。

文件名必须用 .jsonc。如果你同时放了 opencode.json 和 opencode.jsonc,它只读一个——我就是这么浪费了二十分钟调配置。
同目录下还有一个 opencode-mem.jsonc:
{
"autoCaptureEnabled": true,
"opencodeProvider": "deepseek",
"opencodeModel": "deepseek-v4-flash",
"compaction": { "enabled": true, "memoryLimit": 10 },
"chatMessage": {
"enabled": true,
"maxMemories": 3,
"excludeCurrentSession": true,
"injectOn": "first"
}
}
首次运行会自动下载个 ONNX 嵌入模型(nomic-embed-text-v1,547MB),把项目上下文向量化存 SQLite。新对话第一条消息自动注入最多 3 条相关记忆。memoryLimit: 10 控制每个项目最多保留 10 条压缩记忆。这货的缓存目录 ~/.opencode-mem/data/.cache/ 加上日志和记忆数据轻松破 600MB,目前没有自动清理,自己勤快点进去删。
nginx 反代(可选)
我 Homelab 本来就有 nginx 在跑,顺手给 OpenChamber 套了一层。不加也能用——直接 http://开发机:4095 就行。但 iOS App 要求 TLS 才能推送,所以我挂了 HTTPS。
这货的通信不是单一协议——终端走 WebSocket、事件流走 SSE、普通请求走 HTTP。location 规则写错了就各种诡异。贴一下我的配置:
server {
listen 443 ssl http2;
server_name openchamber.your-domain.com;
# WebSocket 终端 — 精确匹配
location = /api/terminal/ws {
proxy_pass http://开发机IP:4095;
include snippets/proxy-websocket.conf;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/global/event/ws {
proxy_pass http://开发机IP:4095;
include snippets/proxy-websocket.conf;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location = /api/event/ws {
proxy_pass http://开发机IP:4095;
include snippets/proxy-websocket.conf;
proxy_buffering off;
proxy_read_timeout 3600s;
}
# SSE 事件流 — 关缓冲、关 gzip
location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ {
proxy_pass http://开发机IP:4095;
include snippets/proxy-common.conf;
proxy_set_header Accept "text/event-stream";
proxy_buffering off;
gzip off;
add_header X-Accel-Buffering "no" always;
proxy_read_timeout 3600s;
}
location ~ ^/api/terminal/.+/stream$ {
proxy_pass http://开发机IP:4095;
include snippets/proxy-common.conf;
proxy_set_header Accept "text/event-stream";
proxy_buffering off;
gzip off;
add_header X-Accel-Buffering "no" always;
proxy_read_timeout 3600s;
}
# 普通 API — 长超时
location /api {
proxy_pass http://开发机IP:4095;
include snippets/proxy-common.conf;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 静态资源
location / {
proxy_pass http://开发机IP:4095;
include snippets/proxy-common.conf;
}
}
nginx 这层踩过几个坑,说说:
location 优先级:
=(精确)>~(正则)>/(前缀)。写反了 WebSocket 请求会被普通 API 规则吞掉,握手直接挂。SSE 被缓冲:nginx 默认缓冲上游响应,SSE 事件攒一波再推给前端,体感就是卡死。
proxy_buffering off+gzip off+X-Accel-Buffering "no"三件套缺一不可。超时:Agent 长任务跑几分钟很正常,默认 60s 的
proxy_read_timeout直接给你 504。WebSocket 头:需要全局
map $http_upgrade $connection_upgrade,location 里设proxy_set_header Upgrade $http_upgrade,不然 Upgrade 不上去。
踩过的几个坑
PATH 没配全,systemd 起不来。systemctl start openchamber 然后 status 显示 node: not found。说了,systemd 不读 .bashrc,PATH 一行不能省。Node、pnpm 全局包、opencode 三个路径一个别少。
异常退出后 4095 "端口被占"。不是真被占,是上次残留了 PID 文件,openchamber 启动检查以为实例还在。所以 ExecStartPre 那行 rm 很关键,前面那个 - 也别漏。
systemctl restart opencode 会话全丢。会话存内存里的,重启就没了。正确姿势是 openchamber restart——OpenChamber CLI 自带的命令,会先通知前端断开再重启后端再恢复会话。systemctl restart 只留给启动失败自动恢复用。
.json 和 .jsonc 同时存在。改了 .jsonc 半天不生效,发现还有个 .json 躺在同目录。删掉一个,只留 .jsonc。
opencode web 默认端口到底是多少。写 opencode web --hostname 127.0.0.1 不知道它起了哪个端口——默认 4096。OPENCODE_HOST 也得对齐到 http://localhost:4096。OpenChamber 是 --port 4095。俩差 1,别混。
ONNX 模型吃磁盘。~/.opencode-mem/data/.cache/ 下 547MB 的嵌入模型是首次用的时候自动下的,加上日志记忆数据超 600MB。目前没有自动清理,定期进去删旧日志。
日常怎么用
在家搞个人项目就浏览器开家里的 OpenChamber,Agent 在服务端跑,本地就个窗口。彩色 diff、文件树、实时进度条,比终端 TUI 舒服多了。

到了公司打开公司那台开发机的 OpenChamber,接着写公司代码。两边各一个,浏览器书签切一下的事,跟切 JetBrains Gateway 的 SSH Host 一样自然。
路上掏出手机,iOS App 连 VPN,两边都能看。改个小 Bug 或者看 Agent 跑得咋样,推送会主动通知你任务完了。
同一个需求我经常丢给 2-3 个模型同时改,在 OpenChamber 里并排看结果,选最好的那个。每个模型跑在独立的 git worktree 里互不干扰。DeepSeek 免费又快,复杂逻辑丢 Kimi。
设好 Session Goal,关电脑关手机,Agent 自己在服务器上跑。第二天来看——这才是这玩意真正的价值。
End
这套东西不是最傻瓜式的——要折腾 systemd、调 PATH、跟 nginx location 优先级斗智斗勇——但跑通之后就是模型自由 + 代码不离开自己机器 + 随时随地能用。对我来说,把 AI Agent 也"远程化"是迟早的事,OpenCode + OpenChamber 在"远程 + 自托管 + 多端"这个三角里刚好踩在我需要的平衡点上。

Comments NOTHING