Kotonia
ログイン今すぐ始める

Kotonia Articles

電車のスマホから自宅 GPU の AI エージェントにコードを書かせる週末 — Web ↔ kotonia-cli daemon を WebSocket で繋いだ話

自宅 PC に常駐させた kotonia-cli daemon を、kotonia.ai の Web 画面と WebSocket で双方向に繋いだ。外出先のスマホから「あのバグ直して」が打てて、家の RTX 6000 Blackwell が動き、worktree がリアルタイムでブラウザに流れてくる。device-code login、sync ApprovalHandler を async WS に橋渡しする話、マルチターン session の SessionRegistry まで、1 週末の設計と実装の細部。

著者 5分で読める
#agent#websocket#rust#個人開発#llm#device-code-flow#remote-development
他の言語英語中国語

電車で揺られている。ふと「あ、あのバグ直さなきゃ」と思い出す。

ポケットからスマホを取り出して、ブラウザで自分のサイトを開く。/agent という新しいページが出迎えてくれる。テキストエリアに src/router.rs の /studio 周りの 500 エラー、再現して直して と打って Run を押す。

家の RTX 6000 Blackwell が動き出す。worktree が /tmp/ に切られて、Agent がコードを読み、bash を叩き始める。$ cargo check[exit 0]$ grep -rn '/studio' src/[exit 0] ... 12 行 という流れが、リアルタイムで僕のスマホの画面に流れてくる。

途中で git push --force を叩こうとして、Approval Required のモーダルがポップする。Deny を押す。Agent が方針を変えて、PR ブランチを切るだけにする。着駅で ── done after 6 iters — ✓ ── を見届けて、ホームで PR URL を確認する。

これがこの週末で動いた光景です。コードは全部自分の PC の中、課金はゼロ、コードベースの内容は 1 文字も外に出ていない。

実際に動かしてる 3 分の様子 (動画は PC ブラウザ、UI はスマホでも同じなので操作感はそのまま再現できる)。Web /agent 画面で task を投げて、家の daemon が bash を叩いて events が流れてくる流れがそのまま映ってる。


1. 何を作ったか

kotonia.ai (自分の個人プロダクト) の Web 画面と、自宅 PC に常駐させる kotonia-cli daemon を、WebSocket で双方向に繋いだ。

[スマホ / PC ブラウザ]
   /agent ページ
        │
        │  HTTPS / SSE (events stream back)
        ▼
[kotonia.ai backend (Rust + Axum)]
        │
        │  WS keepalive every 25s
        ▼
[自宅 PC: kotonia-cli daemon]
        │
        │  spawn agent in fresh git worktree
        ▼
[ローカル LLM (Gemma 4 26B uncensored on :8899)]
        ▲
        │  ReAct loop, bash tools, web search, file fetch
        ▼
[/tmp/kotonia-agent-xxx の worktree でコードを読み書き]

Web 操作の入口は 4 つの新エンドポイント。

MethodPath役割
POST/api/agent-runtime/device-codesCLI が初回ペア用のコードを発行
POST/api/agent-runtime/device-codes/verifyブラウザでコード入力 → 承認
GET/api/agent-runtime/ws/{device_id}daemon の WebSocket 受信口 (Bearer auth)
POST/api/agent-runtime/devices/{id}/agent-taskWeb からタスク投入
GET/api/agent-runtime/devices/{id}/agent-streamevents を SSE で流す

ユーザー視点だと「ペア → daemon 起動 → Web から task → 結果が流れてくる」だけ。実装した差分は 4 commit、累計 ~1,600 行。


2. なぜ作ったか

「外出先のスマホでサクッと自宅のリポジトリを弄れる」状態が長く欲しかった。既存解は 3 系統ある。

既存解嫌なところ
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) の 2 枚 GPU が常駐して、Gemma 4 26B uncensored / DeepSeek V4-Flash / Irodori-TTS / Ditto アバター が常時動いてる。Cloudflare Tunnel で kotonia.ai も自宅 PC から配信してる。

ここに 「タスク投入だけ Web、実行は自宅」 の薄いブリッジを 1 枚足せば、

  • 課金ゼロ (ローカル LLM だけで完結)
  • コードは家から 1 byte も出ない
  • 24/7 GPU 稼働
  • スマホ片手で完結

が手に入る。実際に組んでみたら本当に手に入った、というのがこの週末の結論。


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 の応答を待たないといけない。

採用した解は 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>();

        // 答えが来る前にレースしないよう、先にハンドルを登録
        self.pending.lock().unwrap().insert(approval_id.clone(), tx);

        // WS リーダー側に「承認お願い」を流す
        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 な std::sync::mpsc::recv で待つ。multi-thread runtime 必須。
        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() が unblock
    }
}

トレイトを async に作り変える選択肢もあったけど、CLI 経路 (stdin 承認) と Web 経路 (WS 承認) で 同じ Agent コードを共有する のが要件だったので、sync trait を温存して呼び側だけ橋渡しした。差分 ~150 行で済んだ。

最初のペアリングが curl を 2 回叩く儀式だったのを、kotonia-cli login 1 コマンドに集約した。OAuth 2.0 Device Authorization Grant (RFC 8628) の発想だけ借りて、client_id / client_secret を削いで Cookie 認証に削った形。

$ 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 が polling。approved → used の遷移時に atomic に agent_runtime_devices 行を作って device_token を 1 度だけ返す

approved → used の遷移を atomic にしないと、複数 polling が同時に当たった場合に 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 が無ければここから読む。一度ペアすれば 2 回目以降は kotonia-cli daemon だけで動く。

3.3 SessionRegistry — マルチターンを daemon プロセス側に持つ

Web の /agent タブごとに UUID を 1 つ発番して、その session_id を毎タスクに乗せて送る。daemon 側は HashMap<session_id, Arc<Mutex<SessionState>>> を持って、

  • 1 つの session = 1 つの worktree + 1 つの Agent (会話履歴付き)
  • 続けて投げた task は同じ Agent の run_turn を呼ぶ = 前の context を引き継ぐ
  • 30 分 idle で GC、worktree もまとめて削除
  • WS が切れても session は in-memory に残る (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)~/.kotonia/sessions/{id}.jsonl を自動 attach している点。CLI 一本道で動かしてるときと同じ JSONL フォーマットで履歴が永続化されてる。daemon 再起動でも、Web 画面に /resume <session_id> と打てば自動的に過去履歴を seed して同じ会話を続けられる:

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 を通そうとして 1 個ハマった。Cloudflare Tunnel は idle WS を ~100 秒で切る。何も流れない時間が続くと TCP RST 来て daemon が reconnecting in 5s を 5 秒ごとに繰り返す状態になる。

サーバー側に 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;  // 切断検知 → このタスクも終了
        }
    }
});

接続終了時はチャネル経由で送信失敗 → ping タスクが自然終了する設計にしてある。pinger.abort() も cleanup で呼ぶので 2 段防御。


5. 嬉しいオマケ ─ 監査メール

セッション cookie 盗難で第三者が /agent から好き勝手 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?
----

──────── 日本語 ────────
kotonia.ai でエージェントタスクが実行されました。
心当たりがある場合は無視してください。
心当たりがない場合、セッション Cookie が盗まれている可能性があります:
  1. https://kotonia.ai/account ですべてのセッションをサインアウト
  2. このメールへの返信、または <admin> にご連絡ください

──────── English ────────
(同じ内容を 3 言語)

Mailgun に投げてる既存パターン (OTP 等) に乗せただけだけど、心理的安全度がだいぶ上がる。1 task = 1 email というナイーブ実装で今は十分。


6. UX の細部 ─ スラッシュコマンド

Web の /agent テキストエリアで /help から始まる小さなコマンドセットを実装してある。

コマンド動作
/helpコマンド一覧の modal
/new新 session (worktree フレッシュ + 履歴クリア)
/clearlog だけ消す (session は継続)
/model gemma4-26b-uncensoredモデル切替 + 自動的に /new
/resume <session_id>既存 session に切替、daemon が disk から自動 resume

スマホの小さいキーボードでは /help を頭打つだけで分かる、というのが意外と効く。Claude Code のように「コマンドパレットがある安心感」をスマホでも持ち込めた。


7. まだ穴

正直に書く。

  • 走ってる Agent の interrupt がまだ無い。/abort で停めたいけど、今は新しい task を投げると古いのが session mutex で詰まる
  • multi-device fanout が無い。同じ device_id で 2 つの daemon を上げると latest-wins で片方落ちる
  • /resume の picker UI が無い。session_id を頭で覚えてないと resume できない (kotonia-cli --list-sessions で見るのは別途)
  • スマホ iOS Safari の background tab 切りで SSE が落ちる。手動リロードで再 subscribe する必要あり

このへんは来週末以降の宿題。今は「動く」が大きい。


8. systemd で 24/7 化

これがあると初めて「外出先で雑にタスク投げる」が成立する。hage-* 系の既存パターンに乗せるだけ。

# ~/.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. まとめ ─ ポケットの中の開発環境

実装後の自分の生活はこう変わった。

  • 出先で「あれ直したい」が湧いた瞬間にスマホで打てる
  • 通勤電車で 1 件、コンビニで 1 件、夜ご飯食べながら 1 件、と細切れタスクが進む
  • PC を持ち歩く必要が消えた
  • 課金額は 0 円 / 月。コードは 1 byte も外に出ない
  • 自分の家の GPU が「同僚」みたいに常に待機してる感がある

エンジニア人生で 1 度くらい「ポケットの中の開発環境」が欲しいと思ったことがあるなら、Cloudflare Tunnel + WebSocket + 200 行くらいの sync→async ブリッジで本当に手に入る、という記録です。

実装はオープンソース (github.com/zhener562/kotonia-cli)、kotonia.ai のアカウントを作って kotonia-cli login を叩けば、その日のうちに同じ体験ができる。試したら感想ください。

kotonia.ai / 個人開発で「自分のもの」を増やすシリーズ

Kotonia は音声AI、AIチャット、画像生成、チーム共有をひとつにまとめたAIワークスペースです。

試してみる