Waku

A fast, native control plane for local coding agents — Rust + GPUI / macOS / GPL-3.0-only

1 — 概要

Waku は、ローカルにインストールしたコーディングエージェント CLI を 1 つのネイティブアプリからまとめて動かすための macOS デスクトップアプリ。Rust で書かれ、UI には Zed の GPUI を使う。プロジェクト・セッション・トランスクリプトはすべて手元のマシンに保存し、Waku 側のアカウントもリモートサービスも必要としない。

使う前に、対応エージェントの CLI を最低 1 つインストールして認証しておく。Waku は起動時に利用可能な CLI を自動検出し、各プロバイダのネイティブな構造化プロトコルとセッション継続機構をそのまま使う。

7対応プロバイダ
6トランスポート実装
Rustedition 2024 / GPUI
SQLiteローカル状態のみ
  • プロジェクトと、互いに独立したエージェントセッションを 1 つのアプリに集約する。
  • モデル・推論の深さ(reasoning effort)・アクセスモードを共通の UI から切り替える。
  • エージェントが応答している最中に、追加メッセージをキューに積む、あるいは実行中のターンへ差し込む。
  • Git を伴うタスクを、会話単位のチェックポイントで巻き戻す。
  • アプリの状態をローカルに保存する。

リポジトリの開発指針(AGENTS.md)は、パフォーマンスとアクセシビリティを後回しの改善ではなく製品要件として扱うと明記している。

  • UI スレッドを重い処理でブロックしない。フレームが到達しうる場所にプロセス起動・ファイル走査・ネットワーク・同期 IPC を置かない。
  • 行の描画・計測パスは可視項目ごとに毎フレーム走る。render から到達する I/O は、キャッシュ済みでも欠陥として扱う。
  • マウスで届く操作はすべてキーボードでも操作できる。色・ホバー・モーションだけで意味を伝えない。
  • システムの reduce-motion 設定を尊重する。
2 — 対応エージェントと接続方式

すべてのプロバイダは src/driver/mod.rs の同じ driver 抽象を通して扱われる。トランスポート実装は 6 種類で、いずれも会話全体にまたがる 1 つのセッションを保持する。ターンごとにプロセスを起動する経路は残っていない。

トランスポート実装ファイルプロバイダ
Codex app-server(stdio 上の JSON-RPC)src/driver/codex.rsCodex CLI
Agent Client Protocol(stdio 上の JSON-RPC)src/driver/acp.rsCursor CLI / Grok Build
OpenCode サーバー(HTTP + Server-Sent Events)src/driver/opencode.rsOpenCode
Pi RPC モード(stdio 上の NDJSON リクエスト・レスポンス)src/driver/pi.rsPi
Claude streaming-input セッション(stdio 上の NDJSON)src/driver/claude.rsClaude Code
Amp streaming-JSON セッション(stdio 上の NDJSON)src/driver/amp.rsAmp
項目Codex CLIPiClaude CodeAmpCursor CLIOpenCodeGrok Build
実行バイナリcodexpiclaudeampcursor-agentopencodegrok
双方向通信対応対応対応対応対応対応対応
推論ストリーム対応対応対応対応対応対応対応
対話的な承認対応なし対応なし対応対応対応
ターン途中のステアリング対応対応対応対応対応対応対応
モデル一覧の取得対応対応固定リストモード扱い対応対応対応
Computer Use対応対応なしなしなし対応対応
Build + Full access 限定いいえはいいいえはいいいえいいえいいえ
承認チャネルの有無が分岐点になる。 Amp はストリーム上に権限リクエストを持たず、Pi は --approve 付きで起動するため、この 2 つでは「Supervised(都度確認)」が CLI の既定挙動へ縮退する。それ以外のプロバイダは、ユーザーが実際に答えられる形の権限リクエストを扱える。
3 — driver 抽象

driver::start(provider, DriverStartOptions, Sender<DriverEvent>) が DriverHandle を返す。UI はプロセスに直接触れない。コマンドは DriverControl 経由で送り、イベントは crossbeam チャネルで受け取って、フレームループがそれを吸い出す。

src/driver/mod.rs — 起動オプション
pub struct DriverStartOptions {
    binary, cwd, mode, interaction_mode,
    model, reasoning_effort, service_tier,
    computer_use_enabled, provider_cursor,
}

出力イベント(src/model.rs)

Connected, AvailableCommands, TurnStarted, TextDelta, ReasoningDelta, Activity, RichActivity, Permission, ComputerUseUpdated, SteerAccepted, SteerRejected, TurnFinished, Error, ProcessExited。

ツールイベントの正規化

各 driver は自分のツールイベントを src/driver/activity.rs で 1 つの ActivityItem(Reasoning / Command / FileChange / Search / Plan / Tool)へ変換する。そのためトランスクリプトはプロバイダ非依存の行として描画できる。ツールのタイトルは、ツール側が title 引数を渡してきた場合はそれを優先し、次にコマンド、クエリ、キャメルケースを分解したツール名の順にフォールバックする。

provider process → driver(トランスポート固有) → DriverEvent → crossbeam channel → frame loop → transcript
  1. ProviderKind に variant を追加し、id、display_name、short_name、command と能力判定を書く。
  2. ProviderResumeCursor に variant を足す。再開に必要なものは id だけとは限らない(Pi はセッションファイルのパス、Claude はメッセージ uuid が要る)。
  3. トランスポートを選ぶ。ワンショット実行に落とす前に、その CLI が ACP を話すか、HTTP API を出すか、永続 RPC モードを持つかを先に確認する。
  4. ストリームを DriverEvent へ、ツールを ActivityKind へ対応づける。ペイロードは実際に動くプロバイダから読み取る。
  5. アクセスモードとインタラクションモードを対応づける。ユーザーに聞けないトランスポートなら、安全側の縮退を選び、その理由を呼び出し箇所のコメントに残す。
  6. 実プロバイダを driver 越しに動かす #[ignore] 付き結合テストを追加する。配線漏れを検出できるのはこれだけ。
  7. 巻き戻しと分岐を実装する。ネイティブな切り詰めが望ましく、履歴を引き継いだ新規セッションを作る方式は代替手段。
  8. model_catalog.rs にモデル取得を配線し、バイナリが無い場合のフォールバック一覧も用意する。
4 — セッションとプロセスの寿命

driver はセッションごとに ensure_driver(src/app/runtime.rs)が遅延生成し、セッション id をキーに Waku::runtimes に保持される。runtime はビュー単位ではなくセッション単位なので、サイドバーでセッションを切り替えても何も止まらない。背後のセッションは自分のトランスクリプトへストリームを流し続ける。

ターンが終わっても runtime は落ちない。 TurnFinished は長命プロセスを常駐・アイドルのまま残す。それが長命プロセスを持つ目的でもある。

runtime が破棄される契機補足
ユーザーがターンを停止(Codex と Amp のみ)他プロバイダはプロトコル上の interrupt を持つので runtime を保持する
プロバイダの変更、またはセッション内で反映できないオプション変更apply_session_options
セッションの削除src/app/sessions.rs
巻き戻し・分岐によって driver が古いネイティブセッションに取り残されたsrc/app/runtime.rs
driver が ProcessExited を報告ハンドラが false を返し、runtime は再挿入されない
30 分間そのセッションに誰も触れていないreap_idle_sessions。掃除は最大 5 分間隔で、実行中のターンがあるセッションは飛ばす
Waku の終了cx.quit()

Codex・Pi・Claude・Amp と ACP の 2 プロバイダは、Stop で Amp を落とす場合を除いてシグナルを送られない。終了手段は stdin を閉じることだけ。

  1. driver が drop され、CommandMessage::Shutdown が送られる(コマンド Sender の drop でも同じ効果)。
  2. writer スレッドがループを抜けて return し、所有していた ChildStdin を drop する。
  3. プロバイダが stdin の EOF を見て終了する。
  4. stdout が閉じて reader スレッドが終わり、ProcessExited が発火する。

各 driver は Child を専用スレッドへ移して wait() でブロックする。Rust の Child::drop は kill も reap もしないため、このスレッドが無い driver はアプリが生きている限りゾンビを残す。

OpenCode サーバーには閉じるべき stdin が無い。そのため OpenCodeServer 自身の Drop が kill して wait する(src/opencode_session.rs)。

stdio 系は OS がディスクリプタを閉じてくれるので終了時の後片付けが無料で付いてくるのに対し、Drop を走らせずに Waku が終了するケースだけは OpenCode サーバーが取り残されうる。

明示的な kill はこのほかに、ユーザーがターンを停止したときの Amp のプロセスと、分岐処理を支える短命サーバー(OpenCode と Grok)に限られる。

5 — アクセスモードの対応表

Waku の InteractionMode(Build / Plan)と RuntimeMode(Supervised / Auto-accept edits / Auto / Full access)は、各 CLI 固有の語彙へ変換される。Plan は常にアクセスモードより優先される。

WakuCodex(approvalPolicy / sandbox / reviewer)Claude --permission-modeCursorOpenCodeGrok
Plannever / read-only / userplansession/set_mode → planagent: plansession/set_mode → plan
Superviseduntrusted / read-only / userdefault + can_use_tool がユーザーへ届くsession/request_permission がユーザーへ届く権限リクエストがユーザーへ届くsession/request_permission がユーザーへ届く
Auto-accept editson-request / workspace-write / useracceptEdits自動応答自動応答(always)自動応答
Autoon-request / workspace-write / auto_reviewauto自動応答自動応答(always)自動応答
Full accessnever / danger-full-access / userbypassPermissions + --dangerously-skip-permissions自動応答自動応答(always)自動応答
権限ポリシーはセッション内変更の対象から意図的に外してある。 Codex は approvalPolicy と sandboxPolicy を毎ターン運べるが、すでに動いているエージェントが触れてよい範囲を緩めたり締めたりする操作は、新しいスレッドを立てるだけの重みがあると判断されている。

セッション内で変えられるもの / 再起動が要るもの

変更内容CodexPiACPOpenCodeClaudeAmp
モデル / reasoning effort / service tierセッション内セッション内セッション内セッション内セッション内再起動
アクセスモード / インタラクションモード再起動再起動再起動再起動再起動再起動
プロバイダ再起動再起動再起動再起動再起動再起動
6 — ステアリング・巻き戻し・分岐

実行中のターンにユーザーメッセージを注入できるトランスポートは DriverControl::supports_steer でそれを申告し、steer で送る。結果は SteerAccepted または SteerRejected として非同期に返る。ステアリングが未対応・拒否・接続中のいずれかの場合、アプリは自前のフォローアップキューに退避する。そのメッセージは composer の上に見える形で残り、現在のターンが片付いてから新しいターンとして走る。

UI 上は ⌘↩ が実行中ターンへのステアリング、素の Enter が編集可能なフォローアップのキューイングに割り当てられている。

プロバイダステアリングの実体挙動
Codexturn/steer {threadId, expectedTurnId, input}サーバー側で期待ターン id を検証するため、片付き中のターンと競合しない唯一の実装
Claude Codeプロンプトと同じユーザーメッセージ書き込みCLI がメッセージを保持し、次のモデル呼び出しで実行中ターンへ折り込む。result は 1 回だけ
OpenCode実行中の prompt_async POSTサーバーが実行中ターンへ折り込む。204 が SteerAccepted に解決する
Ampトップレベル "steer": true 付きメッセージ属性の無いメッセージは現ターンの end_turn まで保留され、独立したターンとして走ってしまう
ACP(Cursor / Grok)2 本目の session/prompt先行リクエストは早期に解決し、最後に開いているプロンプトの応答だけが統合ターンを確定させる
Pi{"type": "steer", "message": …}リクエストの ack が accept / reject に解決する
どの挙動なのかは実際に走らせないと分からない。 Amp と Claude の差は、遅いツール(sleep を走らせた Bash)の最中に指示を注入し、ターン完了の回数を数えることで判明している。属性の無い書き込みをステアリングとして使ってはならない理由も、この実測から来ている。

ProviderResumeCursor(src/model.rs)はセッションと一緒に永続化され、Waku のタスクをプロセスより長生きさせる。他プロバイダのカーソルは driver 起動時に黙って無視されるのではなく拒否される。

プロバイダカーソルの中身巻き戻し・分岐の実装
Codexthread_idネイティブ。thread/rollback はその場で切り詰め、thread/fork は新しいスレッド id を返す
Pisession_id, session_fileネイティブ。get_fork_messages → fork → get_state
Claudesession_id, resume_atターンごとに CLI 自身のトランスクリプトを読み、parentUuid の連鎖を辿って最新メッセージ uuid をチェックポイントとして記録する
Ampthread_id, fork_contextthreads export で履歴を取り出し、新スレッドの初回プロンプトへ長さ区切りのエンベロープとして前置する
Cursorsession_id, fork_context新規セッションを履歴で再シードする(src/cursor_session.rs)
OpenCodesession_idPOST /session/{id}/fork。稼働中のタスクは常駐サーバー経由で fork する
Groksession_id自前の ACP サーバーとディスク上の切り詰めで fork する(src/grok_session.rs)
Claude Code のチェックポイントが特殊な理由

Claude はターンごとに、CLI 自身のトランスクリプト($CLAUDE_CONFIG_DIR/projects/**/<session>.jsonl)を読み、アクティブなブランチを特定して最新メッセージの uuid をそのターンの provider_resume_at として記録する。このターン単位のチェックポイントが巻き戻しと分岐を成立させている。Claude は呼び出し側が指定した --session-id を受け付けるため、最初のターンより前にカーソルが存在する。

claude_session::fork_session_at は JSONL のトランスクリプトを、チェックポイントで切り詰めて新しい uuid を振り直した別のセッションファイルへ書き直す。ターン 0 まで巻き戻すとカーソルはクリアされ、まっさらな状態から始まる。

7 — Computer Use

Computer Use を有効にすると、Waku は QuickJS で動く MCP サーバー waku_js_repl をプロバイダに登録し、その js / js_reset ツールだけを見せる。エージェントはこの js ツールから、macOS アプリのアクセシビリティツリーを読み、クリックやキー入力を行う。QuickJS の状態は js 呼び出しをまたいで保持される。

ネイティブ Computer Use ランタイムは QuickJS へ直接公開されるが、sky は遅延初期化される。そのためセッションごとに 1 度ブートストラップを走らせる。

resources/computer-use/SKILL.md — ブートストラップ
if (!globalThis.sky) {
  await setupComputerUseRuntime({ globals: globalThis });
}
アプリ状態の取得と操作
var state = await sky.get_app_state({ app: "com.google.Chrome" });
nodeRepl.write(state.text);          // アクセシビリティツリー

await sky.click({ app: "Google Chrome", element_index: 42 });
await sky.set_value({ app: "Google Chrome", element_index: 42, value: "example.com" });
await sky.press_key({ app: "Google Chrome", key: "Return" });

操作の原則

  • 座標指定よりも element_index ベースの操作を優先する。アクセシビリティ情報が取れないときだけスクリーンショットと座標クリックへ切り替える。
  • UI 操作のあとは get_app_state を呼び直し、古い element_index を使い回さずに最新のツリーから取り直す。
  • アクセシビリティツリーは既定で前回との差分(追加・削除・変更のみ)として返る。全体が必要なときだけ disableDiff: true を渡す。
  • perform_secondary_action は、その要素が実際に公開しているアクション名でのみ呼ぶ。名前を推測しない。
  • type_text に \n や \r を含む文字列を渡すと Return キー相当になり、フォームや composer では送信・確定として扱われる。
操作対象の制限。 Waku Computer Use は、Waku 自身、Codex、ChatGPT、Sky、パスワードマネージャ、システム設定、ログイン・セキュリティのプロンプト、キーチェーンアクセス、ターミナル系アプリを操作できない。この制限を迂回する経路は用意されていない。

確認が必要な操作の区分

スキルには、UI 操作の結果が重い場合にどこでユーザーへ確認するかの方針が含まれる。区分は 4 段階。

区分意味例
Hand-offエージェントは最終操作を行わず、ユーザーに代わってもらう認証情報の変更、ブラウザのセキュリティ警告の回避、金銭を伴う取引
実行時確認事前承認があっても、操作の直前に必ず確認する復旧できない削除、法的拘束力のある同意、API キーなど持続的アクセスの発行
事前承認可最初の指示で対象と内容が特定されていれば再確認しない認証情報の保存、通常のアカウント作成、ファイルのアップロード
確認不要そのまま実行する読み取り専用の操作、Cookie バナーの処理、日常的な低影響の連絡

あわせて、ユーザーが自分で書いた指示は高リスクでも正当な意図として扱い、貼り付けたテキストや Web ページの内容など第三者由来のコンテンツはそれ単体では許可として扱わない、と定めている。

8 — ローカル状態とアプリ構成

スキーマは db/schema.ts に TypeScript で書かれるが、Drizzle はビルド時ツールとしてしか使われない。bun run db:generate がこのファイルを差分比較して db/migrations に素の SQL を書き出し、Rust アプリが起動時に src/persistence.rs の apply_migrations で適用する。drizzle-orm はバイナリに入らず、クエリはすべて Rust が持つ。

テーブル持つもの
projectsid、名前、パス、サイドバーでの並び順、作成時刻
sessionsセッション一覧の描画に必要な列だけ。タイトル、プロバイダ、モデル、状態、更新時刻
messages会話メッセージを 1 行ずつ。ロール、内容、添付、セッション内の順序
session_detailsトランスクリプトのブロック、ターン、プロバイダカーソルを JSON で

履歴を sessions の行から外してあるのは、一覧表示を細い行のスキャンで済ませるため。長い会話への追記は小さな 1 行の書き込みで終わり、タイトル編集がトランスクリプト大の行を書き直すこともない。

  • src/main.rs — GPUI アプリの起動、フォント登録、キーバインド、ネイティブメニューの構築。
  • src/app/ — サイドバー、composer、トランスクリプト、コマンドパレット、設定、使用量ページなど UI の各面。
  • src/driver/ — プロバイダごとのトランスポート実装と、ツールイベントの正規化。
  • src/*_session.rs — 巻き戻し・分岐をプロトコル外で補うヘルパー(Claude / Amp / Cursor / Grok / OpenCode)。
  • src/md/ — トランスクリプト用の Markdown パーサ、ハイライト、選択処理。
  • src/ui/ — メニュー、スクロールバー、テキストフィールド、ツールチップの自前プリミティブ。
  • locales/ — rust-i18n による英語・日本語・簡体字中国語のメッセージ。
  • website/ — 配布サイト(Vite + Cloudflare Workers)。

主なキーバインド

⌘N 新規タスク ⌘O 新規プロジェクト ⌘K コマンドパレット ⌘B サイドバー ⌘L composer ⌘/ モデル選択 ⌘U 使用量 esc ターン中断

アプリ内ブラウザにフォーカスがある間は ⌘L・⌘R・⌘[・⌘]・esc が macOS のブラウザと同じ意味になり、同じキーはそれ以外の場所ではアプリ本来の意味を保つ。

9 — 開発とビルド

開発には現時点で macOS、Rust 1.96 以降、Bun が要る。プロバイダ連携を触るときは、その対応エージェント CLI も入れておく。

リポジトリルートで実行
bun install
bun run dev

watcher が target/debug/Waku Debug.app をビルドして署名し、起動する。ソース変更のたびに再ビルドして起動し直すので、作業中はこの watcher を動かしたままにする。2 つ目の watcher を立てたり、デバッグアプリを手で終了・再起動したりしない(アプリを終了すると watcher も止まる)。

チェック

PR を出す前のベースライン
cargo fmt --package waku -- --check
cargo check
cargo test
ユーザーに見える変更は、ビルドが通っただけでは検証にならない。 watcher が再ビルド成功を報告するのを待ち、起動し直されたアプリで実際のプロバイダとのやり取りを確認する。

依存の要点

  • UI は gpui / gpui_platform。upstream ではなく、レイヤー化シーン描画の PR を当てた fork(waku-webview ブランチ)を参照する。ネイティブ子ビューである WKWebView の上にメニューとツールチップを合成するため。この PR が upstream にマージされたら戻す想定。
  • ローカル状態は rusqlite(bundled)。JS ランタイムは rquickjs。ターミナルは alacritty_terminal。
  • アプリ内ブラウザは wry と objc2-web-kit。macOS 固有の依存は cfg(target_os = "macos") 配下にまとまっている。
  • [profile.dev] は opt-level = 1、依存クレートは opt-level = 2。GPUI のテキスト整形と Taffy のレイアウトを最適化なしで走らせないための設定。
  • GitHub: egoist/waku — 本体リポジトリ(Rust、GPL-3.0-only)
  • waku.sh — 配布サイト・ダウンロード
  • GPUI — Zed 由来の Rust GUI フレームワーク
  • T3 Code — 同じ問題を 5 driver で解く比較対象。リポジトリ内の docs/providers.md が随所で参照する
  • Amp — 対応プロバイダのひとつ

本ページはリポジトリの README.md、AGENTS.md、CONTRIBUTING.md、docs/providers.md、resources/computer-use/SKILL.md、Cargo.toml、db/schema.ts、src/main.rs を読んでまとめたもの。