这篇文章不是发布公告,也不是使用教程——安装、配对、排查的完整步骤在项目的
docs/里都有。我想写的是别的:这个扩展是怎么把"电脑上正在跑的会话"和"口袋里的手机"接起来的——Hook、隧道、Web Push 各解决了哪一环;它基于 MIT 协议的 GitOut 改造,哪些是原项目的骨架、哪些是我重做的;以及它现在还有哪些没做到位的地方。
项目地址:github.com/0-xl-0/xl-code-relay。下面提到的每个机制,在仓库里都能找到对应的实现。

起因:人被困在电脑前面
用 Claude Code 干活的人都熟悉这种感觉:你交代一件事——“跑一遍测试,看看哪些挂了"“把这个模块重构一下”——然后就是等。等它读文件、跑命令、分析、再读文件。短则一两分钟,长则十几分钟。
这段时间你在干什么?大概率是坐在电脑前面刷手机。
这事有点荒谬。任务已经在电脑上跑起来了,人却因为不知道它什么时候结束、不知道它中途会不会停下来问你一句"这个命令能执行吗”,而被钉在工位旁边。你要么盯着那个转圈的界面,要么隔两分钟切回 VS Code 看一眼。
我自己最常遇到的场景是晚上。让 Claude Code 跑一个比较长的分析,人已经躺下了,突然想起来:它要是中途要我确认某个命令怎么办?它跑完了我怎么知道?——于是又爬起来开电脑。
所以就有了一个很朴素的想法:能不能让手机接管这件事?
不是"在手机上用另一个 AI 聊天",那没意义。我要的是:电脑上那个正在跑的会话、那个已经读了几十个文件、记住了整个项目上下文的会话,能从手机上看到它、给它发下一句指令、在它需要我做决定的时候弹一条通知到我手机上。
图里就是最后做出来的东西。左边是你自己的电脑,中间是 Claude Code 会话和本机 Agent,右边是 iPhone 上的网页应用,“你的电脑"那个虚线框就是全部的信任边界——除了让手机连进来的一条隧道,所有东西都留在本机。
先说结论:它是什么
XL Code Relay 是一个 VS Code 扩展,配套一个手机上的网页应用。装上之后:
- 手机能看到你电脑上所有 Claude Code 会话,列表里带着项目名、路径、最后活动时间;
- 挑一个会话,输入一句话发出去,电脑上的 Agent 会执行
claude --resume <会话ID> --print <你的话>; - 结果回到手机上;如果需要批准某个工具调用,手机上会出现一张待确认卡片;
- 结果和审批都会通过 Web Push 变成 iPhone 的系统通知,锁屏上就能看到。
手机端是个 PWA,加到主屏幕之后长得跟原生 App 差不多,中英双语、浅色暗色跟随系统。
把一次任务拆开看,就是上面这张图:任务经隧道进入本机、按策略决定要不要打断你、执行、再把结果和通知送回手机。中间那条"要不要你点头"的岔路,是整套东西里最关键的一环——它决定了你什么时候会被打扰。
**它不是什么:**它不是新的模型,不是云端服务,也不绕过 Claude Code 的任何权限。手机上发的每一句话,最终都在你自己电脑上、用你自己登录的 Claude Code、在你那个项目的目录里执行。它也不提供任何账号——你本来怎么用 Claude Code,现在还怎么用。
为什么不是在 GitOut 上加个按钮
这里得交代一个事实:XL Code Relay 是基于一个叫 GitOut 的 MIT 开源项目改的。
GitOut 已经把最难的地基打好了——远程查看 Claude Code、手机端界面、隧道配对。如果我当初的目标只是"能远程看一眼”,那直接用 GitOut 就够了。
但真用起来,有几个地方是卡住的。
第一个是"发给谁"。 GitOut 的交互更像一个统一的输入口:你发一句话,它交给 Claude Code 处理。可当你有四五个项目、每个项目下又有好几个并行会话的时候,“发给谁"就变成了最关键的问题。你有没有过这种体验——在错误的会话里问了一句正确的问题,然后得到一个完全驴唇不对马嘴的答案?我经常这样。
而 Claude Code 自己的会话日志,其实是天然带项目信息的。它把每个会话存成 ~/.claude/projects/<项目目录>/<会话ID>.jsonl,每个 JSONL 的第一条记录里就有 cwd。也就是说,“这个会话属于哪个项目"这件事根本不需要你去标记,它本来就在那儿。
第二个是"看不到来源”。 手机时间线上如果只有一串 Agent finished、Sent to Claude,你根本分不清哪条来自哪个项目。后来我给每张卡片都加上项目名和路径,这个问题才算解决。
第三个是通知。 GitOut 的消息提醒是在网页里。但我需要的是手机锁屏上的通知——网页里的红点,你得主动打开才能看见,那和"被钉在电脑前"的区别并没有那么大。
所以改动集中在这些地方:会话路由(选会话、按会话恢复执行)、项目上下文卡片、iPhone 原生推送、中文与主题、以及一整套独立于原项目的命名空间(xlCodeRelay.* 设置、~/.xl-code-relay 状态目录、7787/7888 两个端口),避免和原版装在一起时互相打架。
至于那些最基础的骨架——隧道怎么建、Hook 怎么接、手机上怎么渲染——我基本沿用了原来的设计。一个好的地基,值得你只在上面盖自己想盖的那一层。
把任务准确地送回某一个会话
这是整个东西里我最在意的一块,也是改动最彻底的一块(独立成一个 7KB 的 session-router.mjs)。
会话是怎么被找到的
Claude Code 把会话写在 ~/.claude/projects/ 下面。这个目录的结构是:
| |
文件名本身就是 UUID 格式的会话 ID,这给了一个很干净的入口。
但这里有个现实的坑:会话文件会长得非常大。 一个跑了几小时的会话,JSONL 可能有几十兆。你要在手机上刷新一次会话列表,难道把几十个几十兆的文件全读一遍?显然不行。
最后的做法是只读每个文件的首尾各 128KB:
| |
头部读的是 cwd(第一条记录里就有),尾部读的是标题——Claude Code 会把自动生成的会话标题和用户自定义标题作为独立记录追加在后面。读两段、各解析一遍 JSONL、忽略解析失败的行,一个会话的元数据就齐了:{sessionId, cwd, title, ts},而 ts 直接取文件的 mtime。
扫描结果按修改时间倒序取前 60 个,并且有 15 秒的节流——手机端一秒刷新好几次也不会把磁盘读爆。
还有一个细节:如果读出来的 cwd 在磁盘上已经不存在了(项目被删了或者移走了),这条会话会被直接丢弃。你会在手机上看到一堆早就删掉的项目吗?不会。
除了扫描,还有"注册”
光靠扫描有个问题:会话是按 mtime 排序取前 60 个的。如果你有一堆很久没动过的项目,而真正在用的那个会话刚刚才创建,那它当然在列表里;但如果某个会话刚被 Hook 事件激活、文件还没落盘到能被扫到,就可能闪一下不见了。
所以还有第二条路:每次 Claude Code 的 Hook 事件打过来,Agent 都会把 payload 里的 session_id 和 cwd 注册进一张持久化的表(~/.xl-code-relay/sessions.json,最多保留 200 条)。列表最终是"扫描结果 ∪ 注册表"的合并,时间取两者更大的那个。
用两条腿走路的原因很实在:扫描解决"我刚打开手机,要看到所有会话",注册解决"我刚刚在电脑上说话,手机列表要立刻知道"。
手机上的一句话,怎么变成一次后台执行
选好会话、输入消息之后,后端这一句就完了:
| |
--resume 让新的这一轮建立在那个会话已有的上下文之上,cwd 设成会话自己的项目目录——所以它一睁眼就知道自己在哪个项目里。
外面套了几层保护,都是踩过才知道要加的:
- 同一个会话同时只允许一个后台任务。 会话正在跑的时候再发一条,直接返回
session_busy(HTTP 409)。这条规则在 Claude Code 那边也是成立的——--resume一个正在生成的会话本来就会冲突,与其让它失败得莫名其妙,不如在前面就拦住。 - 45 分钟超时。 到点
child.kill(),返回一个明确的timeout结果,而不是让手机永远转圈。 - 输出上限 2MB。 长任务的 stdout 累积到 2MB 就停止追加。
- 消息长度上限 20000 字符。 防止有人把整个文件粘进去。
- Windows 上找不到
claude的处理。 这个值得一提:在 Windows 上claude往往装在%APPDATA%\npm下面,而不是 PATH 里。所以代码会主动把那个目录塞进搜索路径去找claude.exe,最后兜底才用裸名字。也留了XL_CODE_RELAY_CLAUDE_EXECUTABLE环境变量给各种奇怪环境。
跑完之后,让 VS Code 那边也醒过来
这一块是我做得最"脏"、但也最有用的地方。
后台任务跑完了,手机收到结果了——可如果你这时候切回 VS Code,会发现那个 Claude Code 面板还停在原来的状态,你刚在手机上问的问题、Claude 的回答,都不在界面上。因为后台跑的是另一个进程。
修法是在扩展侧轮询:每 2 秒拉一次事件流,发现新出现的 remote-result 事件就按它的会话 ID 重新打开一次:
| |
关掉、等一下、再打开——本质上就是模拟你自己手动关掉对话窗口再重新点开,只不过这个过程被自动化了,而且用 140 毫秒的间隔来等 UI 状态稳定。
老实说这段代码不优雅,我甚至不确定 140ms 在慢一点的机器上够不够。但它解决了"我在手机上干完活,切回电脑发现界面是旧的"这种让人很烦躁的问题。
让电脑端的上下文流向手机
手机能发指令只是单向的。更重要的另一半是:你在电脑上正常用 Claude Code 的时候,手机那边也得跟上。 否则你回到电脑前敲了半小时,手机上却是一片空白。
这一块靠 Claude Code 的 Hooks 实现。
装了哪些 Hook
扩展会在 ~/.claude/settings.json 里注册四个事件:
| 事件 | 什么时候触发 | 手机上变成什么 |
|---|---|---|
UserPromptSubmit | 你提交了一条消息 | You · Message |
Stop | 一轮回答结束 | Claude · Final answer |
Notification | Claude Code 需要你输入 | Agent needs input |
PreToolUse | 即将执行某个工具 | Approve <工具>? |
写入的形态是 HTTP Hook(不是 shell 命令):
| |
两点设计上的克制,我觉得值得说一下。
第一,写入前会弹一个确认框,而且明说"已有 Hook 会被保留"。Hook 配置是个挺敏感的东西——很多人本来就有自己的 Hook 在做别的事。安装过程只往对应事件里 append 一条,并且做幂等检查(已经有一条一样的 HTTP Hook 就跳过)。你可以放心地装、也可以放心地卸。
第二,Hook 服务只监听 127.0.0.1。 7787 这个端口永远只对本机开放,公网压根碰不到它。手机走的是另外的 7888 端口和隧道——两个面是分开的。
从 Hook 到一张可读的卡片
Hook 的原始 payload 挺啰嗦的,直接丢到手机上没法看。中间有一层格式化,把 tool_name 和 tool_input 翻译成人话:
Edit→Edit src/agent.ts,附带删除和插入的行数Write→Write docs/readme.md (1204 chars),附一段预览Bash→ 优先用 Claude Code 自己给的description,没有才退回命令本身Read/Glob/Grep→ 带上文件路径或模式,Read 还会显示行区间AskUserQuestion→ 把问题里的选项抽出来,在手机上渲染成按钮
路径也会被缩短:在项目目录内的显示成 ./相对路径,项目外的只保留最后两段。在手机那块小屏幕上,一个完整的 Windows 绝对路径能占满一整行。
有三类事件不会出现在时间线里
这是个有意的决定,而且是被实际使用体验逼出来的。
Claude Code 每一轮结束都会发 Stop。这个事件本身对用户没意义——“这一轮结束了"是废话,你想看的是它说了什么。所以 Stop 不会变成卡片,它的 last_assistant_message 会被单独取出来,变成一张 Claude · Final answer。
同理,会话正在被后台任务占用时,它产生的事件不会进时间线——因为那些内容马上就要以结果卡的形式出现,重复显示只会让时间线变乱。
还有第三种:手机自己发出的消息会回环。 你从手机发一条消息,Agent 立刻生成一张 You · Message;紧接着 Claude Code 那边因为收到了 prompt,UserPromptSubmit Hook 又打过来一条一模一样的。所以有一层 15 秒内的去重:同会话、同内容、来源是手机的消息,第二次进来的会被丢掉。
最后时间线上只剩下三类东西:你发了什么、Claude 答了什么、有什么需要你决定。 所以内部状态卡片(Agent finished、Sent to Claude 这类)不在手机上渲染——时间线是给人看的,不是给调试用的。
iPhone 的通知,以及一个折腾了很久的 VAPID 问题
为什么必须是"主屏幕应用”
这一点如果不知道,会白白浪费很多时间:iOS 上,网页要收到系统通知(Web Push),必须先把网页添加到主屏幕,再从主屏幕图标打开。
在 Safari 的普通标签页里,Notification.requestPermission() 可能给你一个"允许",然后什么都不会发生。因为 iOS 只把"已经安装到主屏幕的 Web 应用"当作可以接收推送的实体。
所以配对流程里有一步是绕不过去的:Safari 打开配对链接 → 点分享 → 添加到主屏幕 → 从图标打开 → 在设置页允许通知。
那个 BadJwtToken
推送曾经有一段时间一直是坏的。表现很迷惑:通知授权了、订阅登记了、代码里看起来都对,但 iPhone 上就是不响。
排查到最后是 VAPID 的 contact subject。
Web Push 协议要求发起推送的一方在 JWT 里带上一个 sub 声明,标明"如果推送出问题,找谁"。苹果和谷歌都对它有要求:得是个 mailto: 或者 https: 地址。
早期版本里我写的是 mailto:xl@localhost。
这个值能通过本地校验——web-push 这个库只检查协议头是不是 mailto: 或 https:,xl@localhost 完全合格。但 Apple Push 服务会进一步解析这个地址,发现域名根本不可路由,于是返回 BadJwtToken 并拒绝投递。 你从应用侧看不到任何异常:订阅是好的、发送没抛错、日志里连个 warning 都没有。
修法本身很简单:换成 mailto:xl-code-relay@example.com。麻烦的是已经配对过的用户——他们的 config.json 里已经存了旧值,直接改代码不会生效。所以启动时有一段迁移:
| |
同时加了"测试 iPhone 通知"这个按钮,背后是一个受配对码保护的 /api/test-push 接口——按一下,走完整的推送链路发一条测试通知。以后再遇到"通知不响",至少有个手段能立刻分清是链路坏了还是别的原因。
推送本身长什么样
一次推送的 payload 大概是这样:
| |
actionable 决定这条通知带不带按钮。如果是一次需要批准的工具调用,Service Worker 会在通知上生成 ✓ Approve / ✗ Reject 两个 action,并且设置 requireInteraction: true——不需要解锁、不需要打开 App,在锁屏通知上划一下就能批准或拒绝。
发送侧有几个小细节是踩过坑的:
- 每个订阅最多重试 2 次,间隔 350ms。 网络抖动导致的失败占了不少。
- 收到 404 / 410 就把这个订阅删掉。 这是推送服务的标准语义:这个 endpoint 已经失效了(用户卸载了 PWA、重装了系统等等)。重试一万次也不会成功,留着它只会拖慢每次发送。
- 通知的
tag用事件 ID 而不是固定值。 如果用固定 tag,两次推送间隔很近的时候,后一条会把前一条静默替换掉——在用户看来就是"我少收到一条通知"。这个坑在代码里留了注释说明原因。
手机端:一个"能用"的界面
界面这块我没什么好吹的,需求很朴素:能快速看清状态、能快速做出决定。
会话列表里每一项显示 <标题> · <项目名> · <会话ID前8位>,正在后台跑任务的会话会带一个 running 标记。时间线上是三类卡片。有需要决策的事项时,顶部会有一个待处理计数。
调度上做了一个小优化:自动同步在前台是 5 秒一次,退到后台变成 15 秒一次。 手机浏览器对后台标签页的定时器有节流,与其硬顶,不如顺着它。
真正让"实时感"成立的是另外几个触发点——从后台切回前台(visibilitychange)、网络恢复(online)、页面从缓存恢复(pageshow)、以及收到推送时(Service Worker 给页面发 postMessage)。这几个时刻都立刻拉一次数据。用户感知到的"它自己就更新了",其实主要来自这几个时机,而不是那个 5 秒的轮询。
Service Worker 那边只做一件事,但做得挺关键:HTML 走网络优先(cache: 'no-store'),失败才回退缓存。
为什么?因为 iOS 的独立 PWA 有个很烦人的行为:如果不管它,它可能长期用缓存的页面外壳,你怎么更新都看不到新版本。让它每次都去网络要一次 HTML,JS 和字体图标继续走正常 HTTP 缓存——只有那个"外壳"必须是最新的。
现在还没做好的地方
上面讲的都是它现在能做到的事。但我觉得另一半也得写出来——这里有些东西是"看起来能用,其实没接上"的:
pushOnStop/pushOnComplete这两个开关是摆设。 手机上能切、能存进配置、能读回来,但推送逻辑里根本没读它们。关掉也不会有什么变化。replyMode同样没有消费方。 早期设计过"把消息粘到 VS Code 输入框"这条路径,代码还在,但没有任何地方调用它。xlCodeRelay.autoStart实际不生效。package.json里activationEvents是空的,扩展只在你手动执行命令的时候才会被激活。/api/version接口不存在,但手机端每次回到前台都会去请求它(静默 404)。这个机制本来是给自建 Relay 预留的升级检查。- 推送里引用的
/icon-badge.png这个文件不存在,所以通知角标会 404。
这些都不影响主流程,但它们会误导人——一个能点、能保存、但没有效果的开关,比一个不存在的开关更让人困惑。 我的打算是:要么给它们接上真正的实现,要么干脆从界面上撤掉。
安全方面也有几个我认为值得直说的地方:配对码是 6 位十六进制(约 1677 万种组合),没有失败次数限制。这意味着如果有人拿到了你的隧道地址,理论上可以慢慢枚举。实际的防线是"隧道地址本身是随机的、而且只有在你启动时才存在",但这是个偏弱的防线。更稳的做法是加一个失败退避、或者把令牌换成更长的随机串。
所以:只配对你自己的手机,隧道地址和二维码不要往外发,~/.xl-code-relay/config.json 更是绝对不要分享——那里面同时装着配对码、推送私钥和所有订阅信息,拿到它等于完全接管。
几个用得上的场景
说了这么多机制,具体能干什么?我自己最常用的几种:
睡前收个尾。 晚上想把一个比较重的分析或者重构跑完,就在电脑上开好会话,然后带着手机去睡觉。它中途要执行什么命令会推到我手机上,我半睡半醒也能决定批准还是拒绝;跑完了结果也是一条通知,第二天早上直接看结论。
出门前的收尾。 收拾东西的时候突然想起"那个模块的测试还没跑",掏出手机选会话、发一句"跑一下测试,把失败项列出来",然后继续收拾。
离开工位之后的追问。 已经走开了,想起来忘了问一件事,手机上补一句"刚才那个函数为什么用 map 而不是 for 循环?"——因为它建立在原来的会话上,所以它能直接答上来。
这个到底算不算"真的有用"? 我的判断标准很简单:如果它让我少爬起来开一次电脑,它就有用。 按这个标准,它已经赚回成本了。
写在最后
做这个东西的过程中,我最强烈的感受其实和技术无关:Claude Code 这样的工具已经很像一个"同事"了——你交代任务、等它做、它会在不确定的时候来问你。而现在缺的那一环,是"同事找你的时候你不在工位上"。
远程控制本身不是什么新概念。但把"我电脑上那个正在跑的会话"和"我口袋里的手机"接起来,中间要跨过的东西比想象中多:会话识别、上下文同步、内网穿透、iOS 的推送规则、通知的时机和去重……每一项单独看都不难,凑在一起才是一整套东西。
代码在 github.com/0-xl-0/xl-code-relay,想试的话 Release 页面有可以直接安装的扩展包。前面还有很长的路要走——尤其是"把源码公开"和"补上那些没接完的开关"。但至少现在,我可以从沙发上、从床上、从外面,继续跟电脑上那个正在干活的家伙说话了。
这件事本身,就已经挺有意思的。