我用 Codex 做了一个 App,让手机也能控制桌面上的 Codex

99 阅读11分钟

项目地址:rainchestnut/niuma

codex越来越能干了,但是codex只运行在电脑上,只要我坐在电脑前,这不是问题。Codex 能看仓库,能改文件,能跑测试,能处理终端里的各种上下文。问题出现在我离开电脑之后:任务还在跑,审批还在等,Codex 可能需要我补一句话,而我只能等回到电脑前再继续,所以就萌生了一个想法:开发一个连接codex的手机app,我想让电脑在我离开之后也继续替我干活,通过手机能把指令递回电脑上的 Codex,让那台电脑像核动力牛马一样,尽量一直响应我的开发指令,于是,niuma应运而生!

其实国外已经有 Happy 这类工具,用 SSH 的方式支持类似的远程能力。但是需要手机使用一点魔法,也需要一点钞能力。本着最小成本的考虑,不如自己做一个更贴近我需求的免费的连接软件。

先把边界想清楚

我没有一开始就写界面。这个项目最早需要确定的是边界。

如果手机直接连电脑,本地网络、动态 IP、睡眠、权限和安全暴露都会变成问题。如果让 Server 接管业务,开发会简单一些,但 prompt、输出、diff、审批正文都可能进入服务端,这又不是我能接受的信任模型。如果在手机端重新实现一套会话系统,它很快会和 Codex 原本的 thread、turn、item 产生两套真相。

所以 Niuma 的第一条约束是:Codex 仍然是业务真相源。

项目、session、thread、审批和执行结果都应该来自桌面侧 Codex。手机负责看和操作,桌面负责执行,Server 只负责让两端在不直连的情况下找到彼此。

这个判断确定之后,后面的架构基本是被推出来的。

image.png niuma-ios 是我放在手里的控制面。它负责配对、设备列表、项目列表、session 列表、thread 详情、审批入口、附件展示,以及必要的本地缓存和密钥保存。它不是新的 Codex 数据库。

niuma-cli 是桌面上的 Gateway。它才是真正靠近 Codex 的那一层:连接 Niuma Server,启动或连接 codex app-server,把手机发来的请求翻译成 Codex 可以理解的 thread / turn / approval 调用,再把 Codex 的输出投影回手机。

niuma-server 是公网控制面。它负责设备注册、鉴权、配对 token、WebSocket 路由、临时文件 transfer relay 和推送唤醒。它存在的原因是手机和电脑不一定在同一张网络里;它被严格限制的原因是我不希望它接触业务明文。

codex app-server 是 Niuma 接入 Codex 的正式边界。Niuma 不绕过它去猜 Codex 的内部状态,也不自己发明一套替代协议。Local Codex 继续保存和执行真实任务。

这些模块看起来多,但每一层都对应一个现实限制。手机不能直接访问电脑,电脑不能暴露在公网,Server 不能变成业务数据库,Codex 的执行现场又不能离开本机。

Server 不能知道太多

niuma-server 是我反复修改过的一层。

从工程直觉看,让 Server 保存更多东西会方便很多。比如保存 session 列表、保存消息历史、保存 diff 摘要、保存审批详情,移动端刷新时就可以直接查 Server,不需要每次回到桌面侧 Codex。这样做会更像一个常规 Web 后端。

但我不想把 Niuma 做成这种形态。

我写 Codex 任务时,内容可能包含未公开代码、配置路径、构建日志、错误栈和文件变更。Server 如果开始保存这些东西,就算只是“为了体验更好”,它也会从控制面变成业务数据面。这个边界一旦模糊,后面很难再收回来。

所以 Server 最终只做几件事:确认设备身份,确认手机和桌面已经配对,维持两端 WebSocket,路由加密后的消息,临时中转 transfer payload,在任务有新进展时做 APNs 唤醒。它不保存 prompt,不保存输出,不保存审批正文,不保存业务消息历史,也不解释文件名、MIME 或 preview 这些业务含义。

这让 iOS 和 Gateway 的实现更重,但换来的好处很明确:Server 可以稳定地保持 payload-blind。它知道有一台手机和一台桌面在通信,却不需要知道 Codex 具体在处理什么。

但是考虑到文件体积对于交互的影响,server端可能会临时存储一些加密的文件,这也是无奈的折衷之法。

Gateway 最终承担了所有

真正复杂的部分后来集中到了桌面 Gateway。

我最开始也希望它只是一个简单转发层:手机发消息,Gateway 转给 Codex;Codex 有输出,Gateway 转回手机。但真实做起来不是这样。Codex 有 thread、turn、item,有审批,有 request-user-input,有文件变更,有恢复历史。移动端需要看到的是一个稳定的 timeline,而不是一堆原始事件。

所以 niuma gateway 必须承担协议适配。

它要启动或连接 Codex.app 内置的 codex app-server。如果 Codex.app 不存在,再回退到 PATH 上的 codex。它要完成 app-server 初始化,要把移动端的新任务和继续对话映射到 Codex 的 thread / turn 调用,要把 Codex 的通知流整理成手机可消费的消息,还要在断线后通过 thread/read(includeTurns=true) 重新投影历史。

这里有一个我后来才真正重视的问题:顺序不能由手机猜。

长任务、断线重连、历史回放和实时事件混在一起时,如果 iOS 用本地时间、临时 ID 或当前收到消息的顺序来修正 timeline,迟早会出现错位。最后我把顺序放到桌面 Gateway:由 Gateway 按 Codex 的 turn / item 顺序投影 seq,移动端只消费这个结果。

文件也是类似问题。手机发给 Codex 的图片或文件,不能只是一个移动端引用;它最终要在桌面上变成 Codex 可以读取的真实本地文件。反过来,Codex 产生的图片、文件或 diff bundle,也要通过统一的 transfer 机制回到 iOS。这样 transfer_idfile_reffile_type 才能在 iOS、Server、Gateway 和 Codex projection 之间保持一致。

Gateway 变重不是一开始的偏好,而是这些问题逼出来的结果。

配对没有走最省事的路

配对流程也经历过一次明显的改动。

最省事的做法是二维码里放一个 token,iOS 扫码后告诉 Server:这台手机要绑定这台桌面。Server 校验 token,然后创建 binding。这个方案很好写,但它把太多信任放在 Server 身上。

我最后把配对改成了密钥交换过程。

image.png

桌面 Gateway 会生成一次性 pairing key,并把 agent id、pair token、长期签名公钥、长期加密公钥、一次性 pairing public key 和签名放进二维码。iOS 扫码后,用这个 pairing public key 加密自己的长期加密公钥,再把 encrypted handshake 发给 Server。

Server 只验证 token、签名和路由关系,不解密握手内容。它把 encrypted handshake 转发给在线 Gateway。Gateway 解密后保存移动端长期加密公钥,并返回 signed ack。Server 只有在这个 ack 验证通过后,才创建真正的 active binding。

这个流程让配对必须在桌面在线时完成,失败时也需要用户刷新二维码重试。它确实不如简单 token 方案省事,但我需要的是“配对成功”有明确含义:Server 知道绑定关系,手机和桌面也已经完成端侧密钥材料交换。

我把旧实现删掉了

Niuma 不是一次写成现在这个样子的。

早期我其实更希望通过插件解决问题:让插件同步 Codex App 的消息记录和状态信息,再把这些内容传给手机。这个方向听起来最自然,因为 Codex App 已经在电脑上跑着,插件也离它很近。

但我仔细看文档和实际能力之后,发现这条路走不通。如果 Niuma 本身作为插件运行,又通过 Codex 去驱动 Niuma 的能力,很容易出现 Codex 和 Niuma 插件互相调用的循环关系。这容易引发严重的风险,当然可以在启动时禁止codex调用niuma插件,但是无法解决下一个问题,插件并不能复用当前codex app使用的app-server,二是需要重新启用一个,这样就无法实现codex app的消息随着移动端的更新而更新。

所以既然如此,我干脆把桌面运行时独立出来,不再把插件当成长期形态。我把桌面侧改到 Rust niuma-cli:通过 niuma gateway运行,或者通过 niuma service 注册成服务,本地状态统一放在 ~/.niuma,长期桌面 runtime 只有一个,考虑到codex-cli是rust写的,所以niuma-cli也用rust开发,这样如果以后想深度集成也更容易。

这样又出现了一个问题,原先的niuma-server是python做的,ios是swift写的,现在niuma-cli是rust,这就不够统一了,所以干脆把niuma-server用rust重写了,本来准备把niuma-ios也用rust和slint框架重写的,但是考虑到我希望实现的一个语音转文字接口需要用到ios26支持的一个接口,这个用swift更方便,并且目前的ios端已经能用了,就没继续折腾了。

最难的部分

对我来说,最难的是app交互部分,第一个难关,就是我和swift并不熟,准确来说,我对苹果软件开发生态都不熟,万幸有AI,即使我不会,我也能写出软件来。

第二个难关,也是从第一个难关衍生出来的,就是虽然AI也能写,但是我不能判断它的结论是不是对的,它写的代码架构是不是合理的,遇到问题我只能先让AI描述目前的实现逻辑,然后找到问题。

第三个难关就是怎么让AI实现最基本的UI交互设计了,描述稍微模糊一点,AI生成的结果可能就大相径庭,有的时候真的忍不住想破口大骂,这是人的智商能得到的理解吗!我让你改那个你怎么把这个也改了!哦,AI不是人,那没事儿了😂。

历经N次的破防,我终于还是把这部分完成了,但是虽然我写完了,也能用,但是单从实现结果来看,代码量明显偏多,证明里面肯定有冗余设计,重复代码,过度兼容等一系列情况。

配对桌面 Gateway查看设备和项目进入项目 session
继续 Codex thread查看文件变更详情

目前的项目进度

Niuma 现在已经能初步使用,但还远不是一个稳定产品。我现在基本是一边使用一边测试:能配对,能看项目和 session,能继续 thread,也能看到一些文件变更结果。但还有不少问题没有完全修掉,比如 session 顺序偶尔还会乱动,审批消息点下去之后反应不够及时,一些恢复和同步体验也还需要继续打磨。

这也是我暂时没有把它包装成“正式发布”的原因。它更像是一个我自己真实在用、也愿意分享出来让别人一起试的工具。

另外我已经注意到 codex-cli 里出现了远程控制相关的代码提交。OpenAI 官方很可能本来就有移动端或远程控制方向的计划。如果后面官方版本出来,而且体验和边界都足够好,Niuma 可能就没有继续存在的必要了。对我来说这并不是坏事,因为这个项目本来就是为了解决我当前遇到的空档。

回头看

用 Codex 做工程开发,并不是把一句话丢给 AI 就结束了。现在的 AI 仍然需要人给足够明确的边界、足够具体的指令和足够多的验收条件。不然它真的能干出很多让人血压飙升的事情:顺手把一个功能改崩,把一个简单问题打上一堆补丁,或者为了修一个现象引入更多绕路逻辑。

所以我后来给这个项目加了很多限制:Server 不能保存什么,Gateway 不能自己发明什么,iOS 不能猜什么,测试必须跑到什么程度。每当我想加一个更方便的能力时,都要回到最初的问题:这个能力是在加强移动控制通道,还是在把 Niuma 变成另一个 Codex、另一个后端数据库、另一个远程开发环境?

我已经有很长一段时间没有亲手写代码了,都是我出需求,方案和限制,AI负责实现,从某些方面来说,目前的AI写代码的能力已经足够强大了,缺少的可能只是工程思维了。当有一天AI能够在不需要人干预的条件下进行工程开发,我就可以安心的加入外卖大军,为在座的各位服务了😭。