電車で揺られている。ふと「あ、あのバグ直さなきゃ」と思い出す。
ポケットからスマホを取り出して、ブラウザで自分のサイトを開く。/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 つの新エンドポイント。
| Method | Path | 役割 |
|---|---|---|
POST | /api/agent-runtime/device-codes | CLI が初回ペア用のコードを発行 |
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-task | Web からタスク投入 |
GET | /api/agent-runtime/devices/{id}/agent-stream | events を 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 行で済んだ。
3.2 RFC 8628 を Cookie 認証に削った device-code flow
最初のペアリングが 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 だけ:
POST /api/agent-runtime/device-codes(no auth) — CLI が叩く。サーバーはdevice_code(CLI 側秘密) とuser_code(人が読めるコード) を発行POST /api/agent-runtime/device-codes/verify(Cookie auth) — ブラウザでuser_codeを入力 → サーバーはuser_idを結び付けてapproved化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 フレッシュ + 履歴クリア) |
/clear | log だけ消す (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 / 個人開発で「自分のもの」を増やすシリーズ
