Waku
A fast, native control plane for local coding agents — Rust + GPUI / macOS / GPL-3.0-only
Waku は、ローカルにインストールしたコーディングエージェント CLI を 1 つのネイティブアプリからまとめて動かすための macOS デスクトップアプリ。Rust で書かれ、UI には Zed の GPUI を使う。プロジェクト・セッション・トランスクリプトはすべて手元のマシンに保存し、Waku 側のアカウントもリモートサービスも必要としない。
使う前に、対応エージェントの CLI を最低 1 つインストールして認証しておく。Waku は起動時に利用可能な CLI を自動検出し、各プロバイダのネイティブな構造化プロトコルとセッション継続機構をそのまま使う。
- プロジェクトと、互いに独立したエージェントセッションを 1 つのアプリに集約する。
- モデル・推論の深さ(reasoning effort)・アクセスモードを共通の UI から切り替える。
- エージェントが応答している最中に、追加メッセージをキューに積む、あるいは実行中のターンへ差し込む。
- Git を伴うタスクを、会話単位のチェックポイントで巻き戻す。
- アプリの状態をローカルに保存する。
リポジトリの開発指針(AGENTS.md)は、パフォーマンスとアクセシビリティを後回しの改善ではなく製品要件として扱うと明記している。
- UI スレッドを重い処理でブロックしない。フレームが到達しうる場所にプロセス起動・ファイル走査・ネットワーク・同期 IPC を置かない。
- 行の描画・計測パスは可視項目ごとに毎フレーム走る。
renderから到達する I/O は、キャッシュ済みでも欠陥として扱う。 - マウスで届く操作はすべてキーボードでも操作できる。色・ホバー・モーションだけで意味を伝えない。
- システムの reduce-motion 設定を尊重する。
すべてのプロバイダは src/driver/mod.rs の同じ driver 抽象を通して扱われる。トランスポート実装は 6 種類で、いずれも会話全体にまたがる 1 つのセッションを保持する。ターンごとにプロセスを起動する経路は残っていない。
| トランスポート | 実装ファイル | プロバイダ |
|---|---|---|
| Codex app-server(stdio 上の JSON-RPC) | src/driver/codex.rs | Codex CLI |
| Agent Client Protocol(stdio 上の JSON-RPC) | src/driver/acp.rs | Cursor CLI / Grok Build |
| OpenCode サーバー(HTTP + Server-Sent Events) | src/driver/opencode.rs | OpenCode |
| Pi RPC モード(stdio 上の NDJSON リクエスト・レスポンス) | src/driver/pi.rs | Pi |
| Claude streaming-input セッション(stdio 上の NDJSON) | src/driver/claude.rs | Claude Code |
| Amp streaming-JSON セッション(stdio 上の NDJSON) | src/driver/amp.rs | Amp |
| 項目 | Codex CLI | Pi | Claude Code | Amp | Cursor CLI | OpenCode | Grok Build |
|---|---|---|---|---|---|---|---|
| 実行バイナリ | codex | pi | claude | amp | cursor-agent | opencode | grok |
| 双方向通信 | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 |
| 推論ストリーム | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 |
| 対話的な承認 | 対応 | なし | 対応 | なし | 対応 | 対応 | 対応 |
| ターン途中のステアリング | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 | 対応 |
| モデル一覧の取得 | 対応 | 対応 | 固定リスト | モード扱い | 対応 | 対応 | 対応 |
| Computer Use | 対応 | 対応 | なし | なし | なし | 対応 | 対応 |
| Build + Full access 限定 | いいえ | はい | いいえ | はい | いいえ | いいえ | いいえ |
--approve 付きで起動するため、この 2 つでは「Supervised(都度確認)」が CLI の既定挙動へ縮退する。それ以外のプロバイダは、ユーザーが実際に答えられる形の権限リクエストを扱える。
driver::start(provider, DriverStartOptions, Sender<DriverEvent>) が DriverHandle を返す。UI はプロセスに直接触れない。コマンドは DriverControl 経由で送り、イベントは crossbeam チャネルで受け取って、フレームループがそれを吸い出す。
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 引数を渡してきた場合はそれを優先し、次にコマンド、クエリ、キャメルケースを分解したツール名の順にフォールバックする。
ProviderKindに variant を追加し、id、display_name、short_name、commandと能力判定を書く。ProviderResumeCursorに variant を足す。再開に必要なものは id だけとは限らない(Pi はセッションファイルのパス、Claude はメッセージ uuid が要る)。- トランスポートを選ぶ。ワンショット実行に落とす前に、その CLI が ACP を話すか、HTTP API を出すか、永続 RPC モードを持つかを先に確認する。
- ストリームを
DriverEventへ、ツールをActivityKindへ対応づける。ペイロードは実際に動くプロバイダから読み取る。 - アクセスモードとインタラクションモードを対応づける。ユーザーに聞けないトランスポートなら、安全側の縮退を選び、その理由を呼び出し箇所のコメントに残す。
- 実プロバイダを driver 越しに動かす
#[ignore]付き結合テストを追加する。配線漏れを検出できるのはこれだけ。 - 巻き戻しと分岐を実装する。ネイティブな切り詰めが望ましく、履歴を引き継いだ新規セッションを作る方式は代替手段。
model_catalog.rsにモデル取得を配線し、バイナリが無い場合のフォールバック一覧も用意する。
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 を閉じることだけ。
- driver が drop され、
CommandMessage::Shutdownが送られる(コマンドSenderの drop でも同じ効果)。 - writer スレッドがループを抜けて return し、所有していた
ChildStdinを drop する。 - プロバイダが stdin の EOF を見て終了する。
- 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)に限られる。
Waku の InteractionMode(Build / Plan)と RuntimeMode(Supervised / Auto-accept edits / Auto / Full access)は、各 CLI 固有の語彙へ変換される。Plan は常にアクセスモードより優先される。
| Waku | Codex(approvalPolicy / sandbox / reviewer) | Claude --permission-mode | Cursor | OpenCode | Grok |
|---|---|---|---|---|---|
| Plan | never / read-only / user | plan | session/set_mode → plan | agent: plan | session/set_mode → plan |
| Supervised | untrusted / read-only / user | default + can_use_tool がユーザーへ届く | session/request_permission がユーザーへ届く | 権限リクエストがユーザーへ届く | session/request_permission がユーザーへ届く |
| Auto-accept edits | on-request / workspace-write / user | acceptEdits | 自動応答 | 自動応答(always) | 自動応答 |
| Auto | on-request / workspace-write / auto_review | auto | 自動応答 | 自動応答(always) | 自動応答 |
| Full access | never / danger-full-access / user | bypassPermissions + --dangerously-skip-permissions | 自動応答 | 自動応答(always) | 自動応答 |
approvalPolicy と sandboxPolicy を毎ターン運べるが、すでに動いているエージェントが触れてよい範囲を緩めたり締めたりする操作は、新しいスレッドを立てるだけの重みがあると判断されている。
セッション内で変えられるもの / 再起動が要るもの
| 変更内容 | Codex | Pi | ACP | OpenCode | Claude | Amp |
|---|---|---|---|---|---|---|
| モデル / reasoning effort / service tier | セッション内 | セッション内 | セッション内 | セッション内 | セッション内 | 再起動 |
| アクセスモード / インタラクションモード | 再起動 | 再起動 | 再起動 | 再起動 | 再起動 | 再起動 |
| プロバイダ | 再起動 | 再起動 | 再起動 | 再起動 | 再起動 | 再起動 |
実行中のターンにユーザーメッセージを注入できるトランスポートは DriverControl::supports_steer でそれを申告し、steer で送る。結果は SteerAccepted または SteerRejected として非同期に返る。ステアリングが未対応・拒否・接続中のいずれかの場合、アプリは自前のフォローアップキューに退避する。そのメッセージは composer の上に見える形で残り、現在のターンが片付いてから新しいターンとして走る。
UI 上は ⌘↩ が実行中ターンへのステアリング、素の Enter が編集可能なフォローアップのキューイングに割り当てられている。
| プロバイダ | ステアリングの実体 | 挙動 |
|---|---|---|
| Codex | turn/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 に解決する |
sleep を走らせた Bash)の最中に指示を注入し、ターン完了の回数を数えることで判明している。属性の無い書き込みをステアリングとして使ってはならない理由も、この実測から来ている。
ProviderResumeCursor(src/model.rs)はセッションと一緒に永続化され、Waku のタスクをプロセスより長生きさせる。他プロバイダのカーソルは driver 起動時に黙って無視されるのではなく拒否される。
| プロバイダ | カーソルの中身 | 巻き戻し・分岐の実装 |
|---|---|---|
| Codex | thread_id | ネイティブ。thread/rollback はその場で切り詰め、thread/fork は新しいスレッド id を返す |
| Pi | session_id, session_file | ネイティブ。get_fork_messages → fork → get_state |
| Claude | session_id, resume_at | ターンごとに CLI 自身のトランスクリプトを読み、parentUuid の連鎖を辿って最新メッセージ uuid をチェックポイントとして記録する |
| Amp | thread_id, fork_context | threads export で履歴を取り出し、新スレッドの初回プロンプトへ長さ区切りのエンベロープとして前置する |
| Cursor | session_id, fork_context | 新規セッションを履歴で再シードする(src/cursor_session.rs) |
| OpenCode | session_id | POST /session/{id}/fork。稼働中のタスクは常駐サーバー経由で fork する |
| Grok | session_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 まで巻き戻すとカーソルはクリアされ、まっさらな状態から始まる。
Computer Use を有効にすると、Waku は QuickJS で動く MCP サーバー waku_js_repl をプロバイダに登録し、その js / js_reset ツールだけを見せる。エージェントはこの js ツールから、macOS アプリのアクセシビリティツリーを読み、クリックやキー入力を行う。QuickJS の状態は js 呼び出しをまたいで保持される。
ネイティブ Computer Use ランタイムは QuickJS へ直接公開されるが、sky は遅延初期化される。そのためセッションごとに 1 度ブートストラップを走らせる。
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 では送信・確定として扱われる。
確認が必要な操作の区分
スキルには、UI 操作の結果が重い場合にどこでユーザーへ確認するかの方針が含まれる。区分は 4 段階。
| 区分 | 意味 | 例 |
|---|---|---|
| Hand-off | エージェントは最終操作を行わず、ユーザーに代わってもらう | 認証情報の変更、ブラウザのセキュリティ警告の回避、金銭を伴う取引 |
| 実行時確認 | 事前承認があっても、操作の直前に必ず確認する | 復旧できない削除、法的拘束力のある同意、API キーなど持続的アクセスの発行 |
| 事前承認可 | 最初の指示で対象と内容が特定されていれば再確認しない | 認証情報の保存、通常のアカウント作成、ファイルのアップロード |
| 確認不要 | そのまま実行する | 読み取り専用の操作、Cookie バナーの処理、日常的な低影響の連絡 |
あわせて、ユーザーが自分で書いた指示は高リスクでも正当な意図として扱い、貼り付けたテキストや Web ページの内容など第三者由来のコンテンツはそれ単体では許可として扱わない、と定めている。
スキーマは db/schema.ts に TypeScript で書かれるが、Drizzle はビルド時ツールとしてしか使われない。bun run db:generate がこのファイルを差分比較して db/migrations に素の SQL を書き出し、Rust アプリが起動時に src/persistence.rs の apply_migrations で適用する。drizzle-orm はバイナリに入らず、クエリはすべて Rust が持つ。
| テーブル | 持つもの |
|---|---|
projects | id、名前、パス、サイドバーでの並び順、作成時刻 |
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)。
主なキーバインド
アプリ内ブラウザにフォーカスがある間は ⌘L・⌘R・⌘[・⌘]・esc が macOS のブラウザと同じ意味になり、同じキーはそれ以外の場所ではアプリ本来の意味を保つ。
開発には現時点で macOS、Rust 1.96 以降、Bun が要る。プロバイダ連携を触るときは、その対応エージェント CLI も入れておく。
bun install
bun run dev
watcher が target/debug/Waku Debug.app をビルドして署名し、起動する。ソース変更のたびに再ビルドして起動し直すので、作業中はこの watcher を動かしたままにする。2 つ目の watcher を立てたり、デバッグアプリを手で終了・再起動したりしない(アプリを終了すると watcher も止まる)。
チェック
cargo fmt --package waku -- --check
cargo check
cargo test
依存の要点
- 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 を読んでまとめたもの。