Kotonia
ログイン今すぐ始める

Kotonia Articles

在电车上用手机让家里 GPU 上的 AI 帮我写代码 — 一个周末用 WebSocket 把 kotonia-cli daemon 接到 Web /agent 控制台

把 kotonia.ai 的 Web 页面和家里 PC 上常驻的 kotonia-cli daemon 用 WebSocket 双向打通了。在外面用手机敲一句『把那个 bug 修了』,家里的 RTX 6000 Blackwell 就开始干活,worktree 的输出实时流回浏览器。技术细节包括把 sync 的 ApprovalHandler 桥到 async WebSocket、削成 Cookie 认证形状的 device-code login、以及自动从磁盘 resume 的 SessionRegistry,一周末的设计与实现记录。

作者 4分钟阅读
#agent#websocket#rust#独立开发#llm#device-code-flow#remote-development
其他语言日语英语

电车上摇晃着。突然想起来:「啊,那个 bug 得修一下」。

从口袋里掏出手机,浏览器打开自己的网站。一个叫 /agent 的新页面在等着。在文本框里敲『复现并修掉 src/router.rs 里 /studio 周围的 500 错误』,按 Run。

家里的 RTX 6000 Blackwell 启动了。/tmp/ 里切了一个 worktree,agent 开始读代码、敲 bash$ cargo check[exit 0]$ grep -rn '/studio' src/[exit 0] ... 12 行 这种流水实时流到手机屏幕上。

中途 agent 想敲 git push --force,"Approval Required" 弹窗冒出来。按 Deny。Agent 改了主意,只切一个 PR 分支。到站时正好看见 ── done after 6 iters — ✓ ──。站台上确认 PR URL。

这就是这个周末跑通的画面。代码全在自己的 PC 里,账单零,代码库一个字节也没有离开过自家。

实际跑起来的 3 分钟视频 (录像是 PC 浏览器,但 UI 在手机上完全一样,所以操作感受可以直接复现)。在 Web /agent 页面丢一个 task,家里的 daemon 调 bash,events 流回来——整个过程原样呈现。


1. 做了什么

kotonia.ai (我的个人产品) 的 Web 页面,和家里 PC 上常驻的 kotonia-cli daemon,用 WebSocket 双向接通了。

[手机 / PC 浏览器]
   /agent 页面
        │
        │  HTTPS / SSE (events 流回来)
        ▼
[kotonia.ai backend (Rust + Axum)]
        │
        │  WS keepalive 每 25 秒
        ▼
[家里 PC: kotonia-cli daemon]
        │
        │  在新的 git worktree 里启动 agent
        ▼
[本地 LLM (Gemma 4 26B uncensored on :8899)]
        ▲
        │  ReAct loop、bash 工具、web 搜索、文件抓取
        ▼
[在 /tmp/kotonia-agent-xxx 的 worktree 里读写代码]

Web 侧的入口是 4 个新 endpoint。

MethodPath角色
POST/api/agent-runtime/device-codesCLI 申请首次配对用的 code
POST/api/agent-runtime/device-codes/verify浏览器输入 code → 批准
GET/api/agent-runtime/ws/{device_id}daemon 的 WebSocket 接收口 (Bearer auth)
POST/api/agent-runtime/devices/{id}/agent-task从 Web 投 task
GET/api/agent-runtime/devices/{id}/agent-streamevents 用 SSE 流出来

用户视角只剩「配对 → daemon 起动 → 从 Web 投 task → 看结果流过来」。实现总共 4 个 commit,约 1,600 行差分。


2. 为什么要做

「在外面用手机轻轻松松改改家里仓库」这件事我念叨很久了。现成方案分三类。

现成方案不爽的地方
GitHub Codespaces / Cursor Cloud月费。workspace 在云上 = 代码就过去了
ssh + tmux + nvim手机上复杂键位真的难受。盯着长时间 LLM 推理在 ssh 里跑 UX 很差
Claude Code / Codex CLI 配 ssh本质还是 CLI,手机用不了。还要月费

我家里本来就常驻一台 RTX 6000 Blackwell Max-Q (96 GB) + RTX PRO 4000 Blackwell (24 GB) 双 GPU,Gemma 4 26B uncensored、DeepSeek V4-Flash、Irodori-TTS、Ditto avatar 全部 always-on。Cloudflare Tunnel 也已经把 kotonia.ai 从家里这台 PC 配出来了。

在这之上加一层薄薄的 「task 投递在 Web,执行在家里」 桥梁,就能得到:

  • 零月费 (完全本地 LLM)
  • 代码一字节都不出家门
  • GPU 24/7 可用
  • 单手手机操作

实际组装之后,这四样东西真的一周末就到手了。


3. 技术上有意思的 3 件事

3.1 把 sync 的 ApprovalHandler 桥到 async WS

kotonia-cli 原本是 CLI 工具,agent 想敲 destructive 命令时是用 stdin 拿 [y/N] 确认的 sync 结构:

pub trait ApprovalHandler: Send {
    fn ask(&mut self, command: &str, reason: &str) -> ApprovalOutcome;
}

走 Web 路径就得把它变成 「用 WS 发 modal 请求,等浏览器按按钮再返回」ask 是 sync (立即返 ApprovalOutcome),但内部得等一次 async WS round-trip。

最终方案是 tokio::task::block_in_place + std::sync::mpsc

impl ApprovalHandler for WsApprovalHandler {
    fn ask(&mut self, command: &str, reason: &str) -> ApprovalOutcome {
        let approval_id = Uuid::new_v4().to_string();
        let (tx, rx) = std::sync::mpsc::channel::<bool>();

        // 先注册,免得 reader 抢先把答案丢到地上
        self.pending.lock().unwrap().insert(approval_id.clone(), tx);

        // 让 WS reader 把这个请求转给操作者
        let _ = self.out_tx.send(DeviceMsg::ApprovalRequest {
            approval_id: approval_id.clone(),
            task_id: self.task_id.clone(),
            command: command.to_string(),
            reason: reason.to_string(),
        });

        // block_in_place 让 worker thread 能阻塞在 sync recv 上,
        // 同时不冻结整个 multi-thread runtime。要求 multi_thread。
        let approved = tokio::task::block_in_place(|| rx.recv().unwrap_or(false));
        if approved { ApprovalOutcome::Approve } else { ApprovalOutcome::Deny }
    }
}

block_in_place 只在 multi-thread runtime 上有效 (current-thread 会 deadlock),所以 CLI 主体的 #[tokio::main(flavor = "current_thread")] 同步改成了 #[tokio::main] (默认 multi-thread)。

WS reader 那边,从浏览器回来的 ServerMsg::ApprovalResult { approval_id, approved } 找到对应的 sync sender push:

ServerMsg::ApprovalResult { approval_id, approved } => {
    if let Some(tx) = pending.lock().unwrap().remove(&approval_id) {
        let _ = tx.send(approved);  // 上面 ask() 中阻塞的等待被唤醒
    }
}

也可以选择把 trait 改成 async,但需求是 CLI 路径 (stdin 承认) 和 Web 路径 (WS 承认) 共享同一份 Agent 代码,所以保留 sync trait、只在调用点桥接,差分最小,约 150 行。

最初的配对仪式是连敲两次 curl,被 kotonia-cli login 一条命令收拢了。形状借自 OAuth 2.0 Device Authorization Grant (RFC 8628),但因为 Web 侧的 Cookie 认证已经识别了用户,把 client_id / client_secret 削掉了。

$ kotonia-cli login

  Open this URL in a logged-in browser tab:
     https://kotonia.ai/agent/pair

  Then enter this code:
     ABCD-2345

Waiting for approval...... approved!
Paired as device 5b33ef2f.
Saved to /home/zhener/.kotonia/daemon.json

Run `kotonia-cli daemon` to connect.

只有 3 个 endpoint:

  1. POST /api/agent-runtime/device-codes (no auth) — CLI 调。服务器签发 device_code (CLI 的密钥) 和 user_code (人能读的码)
  2. POST /api/agent-runtime/device-codes/verify (Cookie auth) — 浏览器输入 user_code,服务器把 user_id 绑定上去并标 approved
  3. GET /api/agent-runtime/device-codes/{device_code} (no auth) — CLI 轮询。在 approved → used 的那次转换上 atomic 地建 agent_runtime_devices 行,并把 device_token 一次性返出

那次 approved → used 必须 atomic,否则两个并发 poll 同时打进来,device_token 会发出去多次。用 UPDATE ... WHERE status = 'approved'rows_affected() 判,0 就是「别人先拿走了」回 410。

let claimed = sqlx::query(
    "UPDATE agent_device_codes SET status = 'used' \
     WHERE device_code = $1 AND status = 'approved'",
)
.bind(device_code)
.execute(&state.postgres)
.await?;

if claimed.rows_affected() == 0 {
    return Err((StatusCode::GONE, "already consumed".into()));
}
// 只有过了这一行才生成 device_token + DB insert

device_token 由 CLI 写进 ~/.kotonia/daemon.json (chmod 0600),之后每次 kotonia-cli daemon 如果没传 env / flag 就从这里读。一次 pair 之后,第二次起 kotonia-cli daemon 就够了。

3.3 SessionRegistry — multi-turn 放在 daemon 进程里

Web /agent 标签每个 mount 都生成一个 UUID,每个 task 都带上 session_id。daemon 持有一个 HashMap<session_id, Arc<Mutex<SessionState>>>

  • 一个 session = 一个 worktree + 一个 Agent (带会话历史)
  • 连续投的 task 调同一个 Agent 的 run_turn = 前一轮 context 自然延续
  • 30 分钟 idle 就 GC,worktree 一起删
  • WS 掉了 session 不掉 (只要 daemon 进程不死)
struct SessionState {
    agent: Agent,                  // 抱着会话历史一直活着
    workspace: AgentWorkspace,     // /tmp/kotonia-agent-xxx
    last_active: Instant,
}

struct SessionRegistry {
    inner: RwLock<HashMap<String, Arc<AsyncMutex<SessionState>>>>,
}

更爽的是 HistoryStore::open(session_id)自动 attach ~/.kotonia/sessions/{id}.jsonl。和 CLI 一条道用的是同一个 JSONL 格式。daemon 重启之后,在 Web 页面打 /resume <session_id>,过去的历史就自动 seed 回新的 Agent:

let prior = load_session_messages(session_id).unwrap_or_default();
let resuming = !prior.is_empty();
if resuming {
    agent.seed_messages(prior);  // 把过去 messages 原样注入
}

实现完才意识到,这样一来 「家里 CLI 直接敲的会话」和「在外用 Web 的会话」透明地共享同一个对话空间。家里用 kotonia-cli --resume <id> 接着聊。在外用 /resume <id> 接着聊。实机是同一台,所以物理上也一致。


4. 还有一招 ─ Cloudflare Tunnel 跟 WS 的相性问题

WS 想穿 Cloudflare Tunnel 时踩了一坑:idle 的 WS 大概 100 秒就被切断。什么都不流,TCP RST 就来,daemon 进入「每 5 秒 reconnecting」循环。

服务器侧加了每 25 秒一次的 ServerMsg::Ping 推送,daemon 用 DeviceMsg::Pong 回复——application-level keepalive 就把这事压下去了。

let pinger = tokio::spawn(async move {
    let mut tick = tokio::time::interval(PING_INTERVAL);
    tick.tick().await;  // 第 1 次立即触发的丢掉
    loop {
        tick.tick().await;
        if inbox_tx_for_ping.send(ServerMsg::Ping).is_err() {
            break;  // 断开探测到 → 这个任务也终止
        }
    }
});

连接结束时 channel 发送会失败 → ping 任务自然终止。cleanup 里也 pinger.abort(),两重防御。


5. 顺手的好处 ─ 审计邮件

担心 session cookie 被人偷走、第三方在 /agent 上乱投 task,所以 每次 task 提交都给自己邮箱发一封通知

Time:    2026-06-24T03:45:00Z
Device:  laptop (5b33ef2f)
Client:  133.xxx.xxx.xxx
Task:    7f77c5ca

Prompt:
----
can you find novel in this project?
----

──────── 日本語 ────────
(三语 disavow 路径同样格式)

只是借了已有的 Mailgun 集成 (OTP 等),但心理安全感涨了不少。1 task = 1 邮件这种朴素实现暂时够用。


6. UX 细节 ─ 斜杠命令

Web /agent 文本框里实现了一小套 /help 开头的命令。

命令行为
/help命令列表 modal
/new新 session (worktree 重建 + 历史清空)
/clear只清 log (session 继续)
/model gemma4-26b-uncensored切模型 + 自动 /new
/resume <session_id>切到已有 session,daemon 从磁盘自动 resume

手机小键盘上,只敲 /help 就能看到其它命令,这点出乎意料地重要。Claude Code 在桌面给我的「有 command palette 的安心感」搬到手机上了。


7. 还有的坑

老实承认。

  • 运行中 Agent 的 interrupt 还没做。/abort 想要但还没有,新 task 投进去会被 session mutex 堵住等老的
  • multi-device fanout 没做。同一个 device_id 上两个 daemon 会 latest-wins 把先来的踢掉
  • /resume 的 picker UI 没做。要靠脑子记 session_id (kotonia-cli --list-sessions 单独看)
  • 手机 iOS Safari 后台 tab 切割时 SSE 会掉,要手动 reload 重新 subscribe

这些是下个周末以后的功课。这周末「能跑」就已经很大了。


8. 用 systemd 24/7 化

「在外面随手投 task」要真的成立,这一步必不可少。借 hage-* 已有的 systemd --user 模式就行。

# ~/.config/systemd/user/kotonia-cli-daemon.service
[Unit]
Description=kotonia-cli daemon (paired to kotonia.ai)
After=network-online.target

[Service]
ExecStart=%h/.cargo/bin/kotonia-cli daemon --model kotonia-gemma4-26b
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=600

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now kotonia-cli-daemon
loginctl enable-linger zhener  # 登出之后也保持

这之后登出和 PC 重启都不影响 daemon 自动复活。


9. 总结 ─ 口袋里的开发环境

实装之后我的生活变成这样:

  • 在外面冒出「想修一下」的瞬间就能用手机敲
  • 通勤电车一件,便利店一件,吃晚饭一件,碎片 task 一直在推进
  • 不用再背着 PC 出门了
  • 月费 ¥0,代码一字节也不出家门
  • 自家的 GPU 像一个一直待命的同事

工程师人生里如果有过哪怕一次「想要口袋里的开发环境」,用 Cloudflare Tunnel + WebSocket + 大约 200 行 sync→async 桥接就真的能拿到手——这就是这个记录。

代码开源在 github.com/zhener562/kotonia-cli。注册 kotonia.ai 账号,敲一句 kotonia-cli login,当天就能跑起一样的体验。试了之后告诉我感想。

kotonia.ai / 「让自己的东西更多一点」个人开发系列

Kotonia 将语音 AI、AI 聊天、图像生成和团队协作整合到一个 AI 工作区中。

试用 Kotonia