用手机浏览器远程操控电脑上的 Claude Code 会话。
一个基于 Cloudflare Workers + Durable Object 的远程控制桥。它把 Claude Code 的权限请求、回合结束消息和任务中断,通过一个加密的 WebSocket 转发到你的手机浏览器——你可以在不碰电脑的情况下批准命令、回答选择题、发送继续指令,甚至强制中断正在运行的任务。
当前能力:权限决策(允许 / 拒绝 / 总是允许)、AskUserQuestion 选择题远程作答(单选 / 多选 / 自定义输入)、回合结束消息注入、软中断轮询、WebSocket 断线重连快照、ntfy 推送通知,以及一个手机端控制台。
- 📱 手机控制台:浏览器打开即用,无需安装 App。token 通过 WebSocket subprotocol 鉴权,绝不进入 URL。
- ✅ 权限请求远程批准:手机端直接批准 / 拒绝 / 「总是允许这类」,Claude Code 端无需任何操作。
- ❓ 选择题远程作答:当 Claude 使用
AskUserQuestion弹出选项时,手机端渲染真正的选择题卡片,单选点选即提交、多选可勾选、还支持自定义输入兜底。答案通过updatedInput回传,电脑端不弹窗。 - ✋ 软中断:手机端「停止当前任务」通过
PostToolUse命令 hook 轮询生效,结束当前 Bash 工具运行。 - ⚡ 零延迟免打扰:远程模式默认关闭。关闭时手机未连接,hook 立即返回,与没有配置完全一致。
- 🔔 ntfy 推送:远程模式下,挂起的权限请求和回合结束都会推送通知到手机,点通知直达控制台。
- 🔒 安全设计:fail-open 硬约束(任何失败都返回空响应,绝不误放行命令);推送正文不含
tool_input与回复原文;token 通过变量插值注入,不进settings.json。 - 🌐 断线容错:WebSocket 断开自动重连,重连后拉取会话快照,挂起的请求不会丢失。
┌──────────────────┐ ① hook 请求 ┌─────────────────────────────────┐
│ 电脑 · Claude Code │───────────────▶│ Cloudflare Worker │
│ PermissionRequest │ │ /hook/* 路由 + 鉴权 │
│ Stop · PostToolUse│ │ SessionDO(Durable Object) │
└─────────┬─────────┘ │ - 挂起权限 / 回合结束 │
│ │ - SQLite 状态 │
│ ⑤ hook 返回决策 │ - ntfy 推送(远程模式)④ │
└──────────────────────────┴───────┬─────────────┬────────┘
│ ② 广播请求 │ ④ 推送
▼ ▼
┌──────────────┐ ┌──────────┐
│ 手机浏览器 │ │ ntfy.sh │
│ WebSocket ③ │ └──────────┘
│ 作答 · 决策 │
└──────────────┘
- Claude Code 触发
PermissionRequest/Stophook,经 HTTP 转发到 Worker。 - Worker 校验
COMPUTER_TOKEN,把请求交给对应会话的SessionDO。 SessionDO将请求挂起并广播给已连接的手机端;手机端作答后,决策经 WebSocket 送回。- 远程模式下同步推送 ntfy 通知。
- hook 拿到决策结果返回给 Claude Code,继续执行。
fail-open 是硬约束:鉴权失败、请求畸形、网络异常、手机离线、超过超时——以上所有路径都返回空 / 非阻塞的 hook 响应,只意味着「本地正常弹窗」,绝不会因此放行一条命令。
connect/
├─ src/
│ ├─ index.ts # Worker 入口:hook 路由、WebSocket 升级与鉴权
│ ├─ session-do.ts # SessionDO:挂起/决策状态机、SQLite 状态、WS 广播
│ ├─ protocol.ts # 前后端消息协议解析与校验(含 AskUserQuestion 问题解析)
│ ├─ pending.ts # 分级超时等待(立即返回 / 离线等待 / 总超时)
│ ├─ notifier.ts # ntfy 推送适配(未配置 token 时自动降级为空实现)
│ ├─ security.ts # 常量时间比较、base64url、token 提取
│ └─ ui.ts # 手机端控制台 HTML(纯内联,单文件无依赖)
├─ hooks/
│ └─ remote-interrupt.ps1 # PostToolUse 命令 hook:软中断轮询
├─ scripts/
│ ├─ get-session.ps1 # 打印当前会话 ID 与手机控制台 URL
│ └─ measure-baseline.ps1 # 网络层延迟基线测量(直连 vs 代理)
├─ test/ # vitest 测试(协议、状态机、UI、契约)
├─ wrangler.jsonc # Worker 与 Durable Object 配置
└─ package.json
- Node.js 20+(建议 LTS)
- npm
- 一个 Cloudflare 账户(部署时需要)
npm install
Copy-Item .dev.vars.example .dev.vars # 然后替换三个本地 token 值
npm run dev -- --port 8787打开 http://127.0.0.1:8787/s/<session_id>(在手机浏览器),输入 PHONE_TOKEN 即可连接。浏览器会把 token 存在 localStorage,通过 WebSocket subprotocol 发送,从不出现在 URL 里。
session_id从哪来?见下文「连接当前会话」。
要让运行 Claude Code 的电脑与本项目对接,需要两步:配置环境变量 + 在 hooks 中注册。
在运行 Claude Code 的终端(或 VSCode 扩展环境)中设置:
$env:CLAUDE_REMOTE_CONTROL_URL = 'https://your-worker.example.workers.dev'
$env:CLAUDE_REMOTE_CONTROL_TOKEN = '<computer token>'这两个变量会被 PostToolUse 命令 hook 直接读取;HTTP hook 则借助 allowedEnvVars 做变量插值,让 settings.json 里不出现明文 token。
编辑 ~/.claude/settings.json,在保留现有 hooks 的前提下追加以下配置:
{
"hooks": {
"PermissionRequest": [{
"hooks": [{
"type": "http",
"url": "https://your-worker.example.workers.dev/hook/permission",
"timeout": 600,
"headers": { "Authorization": "Bearer $CLAUDE_REMOTE_CONTROL_TOKEN" },
"allowedEnvVars": ["CLAUDE_REMOTE_CONTROL_TOKEN"]
}]
}],
"Stop": [{
"hooks": [{
"type": "http",
"url": "https://your-worker.example.workers.dev/hook/stop",
"timeout": 600,
"headers": { "Authorization": "Bearer $CLAUDE_REMOTE_CONTROL_TOKEN" },
"allowedEnvVars": ["CLAUDE_REMOTE_CONTROL_TOKEN"]
}]
}],
"PostToolUse": [{
"matcher": "Bash(*)",
"hooks": [{
"type": "command",
"shell": "powershell",
"command": "& 'C:\\path\\to\\connect\\hooks\\remote-interrupt.ps1'",
"asyncRewake": true
}]
}]
}
}- 两个 HTTP hook(
PermissionRequest、Stop)同步运行,timeout需大于 worker 端REQUEST_TIMEOUT_MS(默认 590s)。 PostToolUse命令 hook 异步运行,只在 Bash 工具执行后触发,用于软中断轮询。- token 通过
allowedEnvVars插值注入Authorization头,token 不会写进settings.json。
Claude Code 的每个会话 ID 都不同,且随新会话变化(新的 VSCode 窗口、/clear 都会换)。运行:
pwsh -NoProfile -File scripts/get-session.ps1脚本按当前工作目录推导项目目录,取最近修改的会话文件,打印出 Session ID 和手机控制台 URL。在手机打开该 URL,输入 PHONE_TOKEN 即连接。
Claude 需要授权时,手机端卡片会显示工具名与完整 tool_input:
- 允许:放行本次调用。
- 总是允许这类:放行并把权限建议写入规则(
updatedPermissions)。 - 拒绝:拒绝调用,Claude 会收到拒绝消息自行调整。
当 Claude 用 AskUserQuestion 弹出选项时(官方列为「需要用户交互的工具」,任何权限模式都不自动放行),手机端渲染选择题卡片:
- 单选:点选项立即提交,无需额外操作。
- 多选:勾选多项后点「提交答案」。
- 自定义输入:每个问题下方有「其他(自定义回答)」输入框,用于选项都不满意时自由作答。
作答结果通过 updatedInput 回传给 Claude Code,它直接采纳答案继续执行——电脑端不会弹窗。所有交互都保留「拒绝」按钮兜底。
手机端顶部的「继续指令」输入框,只在回合结束、Stop hook 挂起的窗口内生效。三个会被静默丢弃的场景:Claude 还在工作中、远程模式关闭且手机未连接、手机页面切后台导致 WebSocket 断连。
正确用法:先开右上角「远程模式」,等回合结束(出现 running stop hooks 提示或收到推送)再发送。
「停止当前任务」按钮会通过 PostToolUse 命令 hook 轮询 /hook/interrupt。检测到中断标志时,hook 以退出码 2 终止当前 Bash 工具的运行,Claude 随即收到中断反馈。
远程模式默认关闭。关闭时整个系统行为与配置前完全一致:手机未连接 → hook 立即返回 → 正常本地弹窗,零额外延迟。
离开电脑前,在手机控制台右上角打开「远程模式」开关:
- 每个挂起的权限请求和回合结束消息都会发送 ntfy 推送。
- 没有手机连接时,请求会等待
REMOTE_OFFLINE_TIMEOUT_MS(默认 90 秒)让手机连上;连上后最多等满REQUEST_TIMEOUT_MS(总 590 秒);始终没连上 → fail-open。 - 模式到期自动关闭(默认 8 小时),忘了关最多也就耗掉一个晚上。
远程模式依赖 ntfy 通知你「有请求在等你」。安装 ntfy 手机 App,订阅一个别人猜不到的 topic,然后:
wrangler secret put NTFY_TOPIC
⚠️ topic 名本身就是凭据:任何知道 topic 的人都能读到你推送的每一条通知。绝不把它写进wrangler.jsonc,只能用wrangler secret put注入。
推送正文刻意保持最小化:权限推送只含工具名(AskUserQuestion 用固定文案「Claude 在询问问题」),回合结束推送是固定文案。tool_input 与回复原文都没有进入推送的途径——想看详情,点通知打开 token 保护的控制台。
注意:ntfy.sh 免费 tier 有速率限制(429)。推送偶尔会晚到或被丢弃,不影响核心控制功能。
参数都在 wrangler.jsonc 的 vars 中,均为字符串:
| 变量 | 默认值 | 说明 |
|---|---|---|
REQUEST_TIMEOUT_MS |
590000 |
挂起请求的总超时上限(须在 hook timeout 600s 之内) |
REMOTE_OFFLINE_TIMEOUT_MS |
90000 |
远程模式下,等待手机连接的第一段窗口 |
REMOTE_MODE_TTL_MS |
28800000 |
远程模式自动过期时长(8 小时) |
MAX_RECENT_MESSAGES |
50 |
会话快照保留的最近回复条数(上限 200) |
完整流程(本机验证 → 写入三个 secret → 部署):
npm run types # 重新生成 wrangler 类型
npm run typecheck # TypeScript 类型检查
npm test # 运行全部测试
npm run deploy:check # wrangler deploy --dry-run 预检
wrangler secret put COMPUTER_TOKEN
wrangler secret put PHONE_TOKEN
wrangler secret put NTFY_TOPIC
npm run deploy三个 secret 的用途:
| Secret | 用途 |
|---|---|
COMPUTER_TOKEN |
电脑端 hook 请求的鉴权(Authorization: Bearer) |
PHONE_TOKEN |
手机端 WebSocket 连接鉴权(subprotocol) |
NTFY_TOPIC |
ntfy 推送 topic(topic 名即凭据) |
- fail-open 硬约束:缺凭据、畸形输入、网络错误、手机离线、超时——全部返回空 / 非阻塞 hook 响应,绝不返回 allow。安全隐患的代价是「回到本地弹窗」,而不是「放行危险命令」。
- 凭据不进配置文件:
COMPUTER_TOKEN/PHONE_TOKEN/NTFY_TOPIC仅通过wrangler secret put注入;电脑端 token 靠allowedEnvVars变量插值进 HTTP hook 头。 - 隐私最小化推送:推送正文不含
tool_input,不含回复原文。 - Token 校验:凭据比对用常量时间比较(
timingSafeEqual)防时序侧信道;手机 token 经 subprotocol 传输,不在 URL、不在 GET 参数。
npm test # 一次运行
npm run test:watch # 监听模式测试覆盖:hook / 客户端消息协议解析、SessionDO 状态机(挂起、决策、远程模式、TTL 过期)、手机端控制台渲染、fail-open 契约。
| 脚本 | 说明 |
|---|---|
pwsh -NoProfile -File scripts/get-session.ps1 |
打印当前会话的 Session ID 与手机控制台 URL |
pwsh -NoProfile -File scripts/measure-baseline.ps1 |
测量到 Cloudflare 边缘的往返延迟(直连 vs 代理),判定线 p95 < 300ms |
手机端点了按钮,电脑端却没反应? 大概率是手机页面的 WebSocket 已断连(切后台太久 / 重新部署过)。确认右上角状态为「已连接」,必要时刷新页面。挂起的请求不会丢失,重连后仍在。
为什么本地还是会弹窗? 离线、远程模式关闭、超时都会走 fail-open 让本地正常弹窗;这是设计使然。开着远程模式且手机在线时,决策才由手机决定。
部署后手机上看到的还是旧界面? 控制台 HTML 是静态送达,下拉刷新可能命中缓存。完全关闭标签页重新打开即可拿到最新版本。
npm run dev 报 stdin is not a tty?
Git Bash 下 node 常被 alias 成 winpty node.exe,非交互 stdin 会报错。用完整 node 路径(如 C:\Program Files\nodejs\node.exe)启动即可。
本项目采用 MIT License。