シャドウ・ワークスペースで反復する
ユーザーへ影響を与えずにAIがコードを反復改良できるよう、非表示ウィンドウとカーネルレベルのフォルダプロキシを活用。
失敗のレシピはこうです。いくつかの関連ファイルをGoogleドキュメントに貼り付け、あなたのコードベースを何も知らないお気に入りのp60ソフトウェアエンジニアにリンクを送り、そのドキュメント内で次のPRを完全かつ正確に実装するよう頼むのです。
同じことをAIに頼んでも、予想どおり失敗します。
代わりに、リントの確認、定義へのジャンプ、コード実行ができる形で開発環境へのリモートアクセスを与えれば、実際に多少は役に立ってくれることを期待できるでしょう。

私たちは、AI があなたのコードのさらなる部分を書けるようにする鍵の一つは、開発環境内で反復できることだと考えています。しかし、無計画に AI をあなたのフォルダで自由に動かすと混乱を招きます。たとえば、思考負荷の高い関数を書き上げたのに AI に上書きされてしまったり、プログラムを実行しようとしたのに AI がコンパイルできないコードを差し込んでしまったりするかもしれません。本当に役立つためには、AI の反復はあなたのコーディング体験に影響を与えず、バックグラウンドで行われる必要があります。
これを実現するために、私たちはシャドウ・ワークスペースと呼ぶ仕組みをCursorに実装しました。本記事では、まず設計上の要件を概説し、そのうえで執筆時点でCursorに存在する実装(非表示のElectronウィンドウ)と、将来的に目指している姿(カーネルレベルのフォルダプロキシ)について説明します。

設計基準
シャドウワークスペースで、次の目標を達成したいと考えています。
- LSP の使い勝手:AI は自分の変更によるリントを確認し、定義へ移動でき、さらに一般的にはlanguage server protocol(LSP)のあらゆる部分とやり取りできるべきです。
- 実行可能性:AI は自分のコードを実行し、出力を確認できるべきです。
最初はLSPの使いやすさに注力します。
目標は、次の要件を満たしたうえで達成される必要があります。
- 独立性: ユーザーのコーディング体験は影響を受けてはならない。
- プライバシー:ユーザーのコードは安全であるべきです(例:すべてをローカルに保持するなど)。
- 並行性:複数のAIが同時に作業できるべきです。
- 普遍性:すべてのプログラミング言語とあらゆるワークスペース構成で機能するべきです。
- 保守性:可能な限り少なく、かつ分離しやすいコードで記述されているべきです。
- 速度:どこでも数分待たされるような遅延があってはならず、何百もの AI ブランチに対応できる十分なスループットが必要です。
これらの多くは、十数万人規模のユーザーに向けてコードエディタを構築するという現実を反映しています。私たちは、誰かのコーディング体験に悪影響を与えたくありません。
LSP の実用性を実現する
AI が自動修正(lint)を用いて自分の編集に対する指摘を受け取れるようにすることは、基盤となる言語モデルを固定したままコード生成の性能を向上させる最も効果的な方法の一つです。lints は、動作率 90% のコードを 100% 動作するコードへ引き上げられるだけでなく、AI が最初の試行で呼び出すべきメソッドやサービスを推測せざるを得ない、コンテキストが限られた状況でも非常に有用です。lints によって、AI が追加情報を求める必要がある箇所を特定できます。

LSP の使いやすさは実行可能性よりも単純です。というのも、ほぼすべての Language Server は、ファイルシステムに書き込まれていないファイルでも動作できるからです(そして後ほど見るように、ファイルシステムを絡めると話はかなり難しくなります)。そこで、まずはここから始めましょう! 5 つ目の要件である保守性の精神に則り、私たちはまず最も単純な解決策から試しました。
うまくいかない単純な解決策
CursorがVS Codeのフォークであることは、言語サーバーへ非常に簡単にアクセスできることを意味します。VS Codeでは、開いている各ファイルはメモリ上にそのファイルの現在の状態を保持する TextModel
オブジェクトで表現されます。言語サーバーはディスクからではなくこれらのテキストモデルオブジェクトから読み取るため、保存時だけでなく入力中にも補完やLintを提供できるのです。
AI がファイル lib.ts
に編集を加えるとします。ユーザーが同時に編集中かもしれないため、lib.ts
に対応する既存の TextModel
オブジェクトを変更することはできません。とはいえ、もっともらしい発想としては、TextModel
オブジェクトのコピーを作成し、そのコピーをディスク上の実ファイルから切り離し、AI にそのオブジェクトを編集させてリントを受けさせる、というものです。これは次の 6 行のコードで実現できます。
async getLintsForChange(origFile: ITextModel, edit: ISingleEditOperation) {
// create the copied in-memory TextModel and apply the AI edit to it
const newModel = this.modelService.createModel(origFile.getValue(), null);
newModel.applyEdits([edit]);
// wait for 2 seconds to allow language servers to process the new TextModel object
await new Promise((resolve) => setTimeout(resolve, 2000));
// read the lints from the marker service, which internally routes to the correct extension based on the language
const lints = this.markerService.read({ resource: newModel.uri });
newModel.dispose();
return lints;
}
この解決策は、保守性の面で明らかに優れています。プロジェクトごとに適切な言語固有の拡張機能を多くの人がすでにインストールし設定しているため、汎用性の面でも優秀です。並行性とプライバシーについても容易に満たせます。
問題は独立性にあります。コピーした TextModel
を作ることで、ユーザーが編集中のファイルを直接変更しているわけではありませんが、私たちは依然として、ユーザーが使用しているのと同じ言語サーバー に、コピーしたファイルの存在を知らせています。これによって問題が生じます。go-to-references の結果にコピーしたファイルが含まれてしまうほか、Go のようにデフォルトで複数ファイルにまたがる名前空間スコープを持つ言語では、コピーしたファイルとユーザーが編集している元のファイルの両方で、すべての関数に重複した宣言があると警告されます。一方で、Rust のようにどこかで明示的にインポートされた場合にのみファイルが含まれる言語では、まったくエラーが出ません。おそらく、同様の問題はまだ数多く存在します。
これらの問題は些細に聞こえるかもしれませんが、私たちにとって独立性は絶対に不可欠です。コード編集という通常の体験がわずかでも損なわれるなら、AI機能がどれほど優れていても関係ありません—私自身を含め、人々はCursorを使わなくなるでしょう。
最終的にうまくいかなかった他の案もいくつか検討しました。たとえば、VS Code のインフラの外で独自に tsc
や gopls
、rust-analyzer
のインスタンスを起動すること、すべての VS Code 拡張が動作する拡張ホストプロセスを複製して各言語サーバー拡張を二重起動できるようにすること、そして、すべての主要な言語サーバーをフォークして複数のバージョンのファイルをサポートさせたうえで、それらの拡張を Cursor に同梱することなどです。
現在のシャドウワークスペースの実装
最終的に、私たちはシャドウワークスペースを不可視ウィンドウとして実装しました。AI が自分で生成したコードのリントを確認したいときは、現在のワークスペース用に不可視ウィンドウを生成し、そのウィンドウ内で編集を行ってリント結果を返します。リクエスト間でこの不可視ウィンドウを再利用します。これにより、すべての要件を(ほぼ*)満たしつつ、(ほぼ*)完全な LSP の使い勝手を得られます。アスタリスクの注記は後述します。
簡略化したアーキテクチャ図を図4に示します。

AI は通常ウィンドウのレンダラープロセス内で動作しています。自分が書いたコードに対する lint 結果を確認したいとき、レンダラープロセスはメインプロセスに同じフォルダー内で非表示のシャドウウィンドウを生成するよう依頼します。
Electron のサンドボックス化により、2 つのレンダラー・プロセスは直接通信できません。検討した選択肢のひとつは、VS Code が実装している慎重に設計された Message Port 作成ロジックを再利用し、レンダラー・プロセスから拡張機能ホスト・プロセスへの通信を可能にしている仕組みを流用し、通常ウィンドウとシャドウウィンドウ間の独自の Message Port IPC を作ることでした。保守負荷を懸念し、私たちはハック寄りの手法を選びました:既存の「レンダラー → 拡張機能ホスト」の Message Port IPC をそのまま使い、拡張機能ホスト同士の通信は独立した IPC 接続で行う、というものです。そこで、ちょっとした利便性の向上も忍ばせました:VS Code のカスタムでやや脆い JSON シリアライゼーション・ロジックの代わりに、gRPC とbuf(私たちのお気に入り)で通信できるようになったのです。
このセットアップは自動的にかなり保守しやすく、追加したコードは他のコードから独立しており、ウィンドウを非表示にするために必要な中核のコードはたった1行です(Electronでウィンドウを開く際に、非表示にするためのパラメータ show: false
を指定できます)。また、普遍性とプライバシーの要件も容易に満たします。
幸い、独立性も満たされています。新しいウィンドウはユーザーから完全に独立しているため、AI は必要な変更を自由に実行し、それに対するリント結果を取得できます。ユーザーは何も気づきません。
シャドウウィンドウには1つ懸念があります。新しいウィンドウは単純にメモリ使用量が2倍になります。これを抑えるため、シャドウウィンドウで実行を許可する拡張機能を制限し、15分間操作がなければ自動的に終了し、さらにオプトイン方式にしています。とはいえ、同時実行の観点では課題があります。各AIごとに新しいシャドウウィンドウを単純に立ち上げることはできません。幸い、ここではAIと人間の決定的な違いの1つを活用できます。AIは、気づくことなく無期限に一時停止させられるのです。具体的には、2つのAI、AとBがいて、それぞれがA1に続いてA2、B1に続いてB2という編集案を提示している場合、それらの編集をインターリーブできます。シャドウウィンドウはまずフォルダ全体の状態をA1にリセットし、リント結果を取得してAに返します。次に、フォルダ全体の状態をB1にリセットし、リント結果を取得してBに返します。以降、A2、B2についても同様に進めます。この意味では、AIは(CPUによって気づくことなく同様にインターリーブされる)コンピュータプロセスに、人間(生来の時間感覚を持つ)よりも近い存在だと言えます。
これらすべてを組み合わせると、バックグラウンドのAIがユーザーへ一切影響を与えることなく編集内容を洗練できる、シンプルな Protobuf APIが得られます。
お約束のアスタリスクについて: 一部の言語サーバーは、リント結果を報告する前にコードがディスクへ書き込まれていることに依存します。代表的な例は rust-analyzer
言語サーバーで、これはプロジェクト単位の cargo check
を実行してリントを取得するだけであり、VS Code の仮想ファイルシステムと統合していません(参考:この Issueを参照)。そのため、Shadow Workspace は、ユーザーが非推奨の RLS
拡張機能を使用していない限り、Rust に対する LSP の実用性をまだサポートしていません。
実行可能性の確保
実行可能性は、物事が興味深く、かつ複雑になる領域です。現在私たちは Cursor 向けに、全体の PR を実装するのではなく、あなたが使っているあいだにバックグラウンドで関数を実装する、といった短時間スケールの AI に注力しているため、実行可能性はまだ実装していません。それでも、これをどう実現するか考えるのは楽しいものです。
コードを実行するには、ファイルシステムに保存する必要があります。多くのプロジェクトには、ディスクベースの副作用(ビルドキャッシュやログファイルなど)も発生します。したがって、シャドウウィンドウをユーザーと同じフォルダで起動することはできなくなりました。すべてのプロジェクトを確実に実行可能にするにはネットワークレベルの分離も必要ですが、現時点ではディスクの分離の実現に注力します。
最もシンプルな考え方:cp -r
最もシンプルな発想は、ユーザーのフォルダを再帰的に/tmp
の場所へコピーし、そこにAIの編集を適用してファイルを保存し、コードをそこで実行することです。次に別のAIが編集する際は、シャドウワークスペースがユーザーのワークスペースと同期し続けるように、rm -rf
の後に新しくcp -r
を実行します。
問題は速度です。cp -r
は本当に遅い。ここで押さえておくべきなのは、プロジェクトを実行できる状態にするにはソースコードをコピーするだけでなく、ビルド関連の補助ファイルもすべてコピーする必要があるという点です。具体的には、JavaScript プロジェクトでは node_modules
、Python プロジェクトでは venv
、Rust プロジェクトでは target
をコピーする必要があります。これらは一般に、中規模のプロジェクトでも巨大なフォルダであり、素朴な cp -r
アプローチに終止符を打ちます。
シンボリックリンク、ハードリンク、コピーオンライト
大規模なフォルダ構造のコピーや作成は、必ずしもとても遅い必要はありません。実例としてはbunがあり、キャッシュ済みの依存関係を node_modules
にインストールする際に、しばしばサブ秒で完了します。Linux ではハードリンクを使っており、実際のデータ移動がないため高速です。macOS では、比較的最近追加されたclonefile システムコールを使用しており、ファイルやフォルダに対してコピーオンライトを行います。
残念ながら、当社の中規模モノレポでは、cp -c
の clonefile だけでも完了に 45 秒かかります。これはシャドウワークスペースの各リクエスト前に実行するには遅すぎます。ハードリンクは、シャドウフォルダ内で実行した処理が元のリポジトリの実ファイルを誤って変更してしまう可能性があるため、危険です。シンボリックリンクも同様で、さらに透過的に扱われない問題があり、しばしば追加の設定が必要になります(例:Node.js の --preserve-symlinks フラグ)。
たとえば clonefile(あるいは素朴に cp -r
)でも、うまく勘定方式を組み合わせて各リクエストのたびにフォルダを再コピーしなくて済むようにすれば、何とかなるかもしれません。正しさを担保するには、最後にフルコピーしてからユーザーのフォルダで起きたすべてのファイル変更と、コピー先フォルダでのすべてのファイル変更を監視し、各リクエストの前に後者を巻き戻して前者を再適用する必要があります。どちらか一方の変更履歴が追いきれないほど大きくなったときは、新たにフルコピーを行って状態をリセットすればよいでしょう。実現は可能かもしれませんが、バグを生みやすく、脆く、そして率直に言って、こんなに単純に聞こえる目的のためにはいささか見た目が悪い方法です。
本当に求めているもの:カーネルレベルのフォルダプロキシ
本当に求めているのはシンプルです。通常のファイルシステムAPIを使うすべてのアプリケーションから見て、ユーザーのフォルダAとまったく同一に見えるシャドウフォルダA′を用意しつつ、内容をメモリから読み出す少数のオーバーライドファイルを素早く設定できるようにしたいのです。さらに、フォルダA′への書き込みはディスクではなくインメモリのオーバーライドストアに書かれるようにしたい。要するに、オーバーライドを設定可能なプロキシフォルダが欲しく、オーバーライドテーブルは完全にメモリ上に保持して構いません。そうすれば、このプロキシフォルダ内にシャドウウィンドウを生成し、ディスクレベルでの完全な独立性を達成できます。
重要なのは、フォルダプロキシに対してカーネルレベルのサポートが必要だという点です。これにより、実行中のコードが何の変更もなく read
および write
システムコールを引き続き呼び出せるようにします。1つのアプローチは、カーネルの仮想ファイルシステムにおいてシャドウフォルダのバックエンドとして自身を登録するカーネル拡張 13 を作成し、上記で概説した単純な動作を実装することです。
Linux では、代わりにユーザーレベルでこれを実現できます。FUSE(“Filesystem in Userspace”)を使います。FUSE は多くの Linux ディストリビューションで既定で提供されているカーネルモジュールで、ファイルシステム呼び出しをユーザーレベルのプロセスへプロキシします。これにより、フォルダープロキシの実装はさらに簡単になります。フォルダープロキシの簡易実装は次のようになります。以下に C++ で示します。
まず、FUSE カーネルモジュールとの通信を担うユーザーレベルの FUSE ライブラリをインポートします。併せて、対象フォルダ(ユーザーのフォルダ)と、オーバーライドのインメモリマップも定義します。
#define FUSE_USE_VERSION 31
#include <fuse3/fuse.h>
// other includes...
using namespace std;
// the proxied folder that we do not want to modify
string target_folder = "/path/to/target/folder";
// the in-memory overrides to apply
unordered_map<string, vector<char>> overrides;
次に、独自の read
関数を定義し、overrides にそのパスが含まれているかを確認し、含まれていない場合はターゲットフォルダから読み込みます。
int proxy_read(const char *path, char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
// check if the path is in the overrides
string path_str(path);
if (overrides.count(path_str)) {
const vector<char>& content = overrides[path_str];
// if so, return the content of the override
if (offset < content.size()) {
if (offset + size > content.size())
size = content.size() - offset;
memcpy(buf, content.data() + offset, size);
} else {
size = 0;
}
return size;
}
// otherwise, open and read the file from the proxied folder
string fullpath = target_folder + path;
int fd = open(fullpath.c_str(), O_RDONLY);
if (fd == -1)
return -errno;
int res = pread(fd, buf, size, offset);
if (res == -1)
res = -errno;
close(fd);
return res;
}
カスタムのwrite
関数は、オーバーライド用のマップに書き込むだけです。
int proxy_write(const char *path, const char *buf, size_t size, off_t offset, struct fuse_file_info *fi)
{
// always write to the overrides
string path_str(path);
vector<char>& content = overrides[path_str];
if (offset + size > content.size()) {
content.resize(offset + size);
}
memcpy(content.data() + offset, buf, size);
return size;
}
最後に、カスタム関数を FUSE に登録します。
int main(int argc, char *argv[])
{
struct fuse_operations operations = {
.read = proxy_read,
.write = proxy_write,
};
return fuse_main(argc, argv, &operations, NULL);
}
実際の実装では、readdir
、getattr
、lock
を含む FUSE API 全体を実装する必要がありますが、各関数は上記のものと非常によく似たものになります。新しいリントのリクエストごとに、その特定の AI による編集のみを含むように overrides マップをリセットすればよく、これは即時に行えます。メモリ逼迫を確実に防ぎたい場合は、(多少の追加の記帳処理が必要になりますが)overrides マップをディスク上に保持する方法もあります。
環境を完全に制御できるのであれば、FUSE によるユーザー/カーネル間の余分なコンテキストスイッチのオーバーヘッドを避けるために、代わりにネイティブのカーネルモジュールとして実装したいところです。14
…しかし:ウォールドガーデン
Linux では FUSE のフォルダプロキシがうまく機能しますが、当社のユーザーの多くは macOS か Windows を使用しており、どちらにも FUSE の実装が標準搭載されていません。残念ながら、カーネル拡張を同梱することも論外です。Apple Silicon 搭載の Mac では、ユーザーがカーネル拡張をインストールする唯一の方法は、特殊なキーを押しながら再起動してリカバリモードに入り、「Reduced Security(低セキュリティ)」へ格下げすることです。とても出荷できる代物ではありません!
FUSEは一部をカーネル内で実行する必要があるため、macFUSEのようなサードパーティ製のFUSE実装は、ユーザーにインストールしてもらうのがほぼ不可能という同じ問題に直面します。
この制約を回避するため、創意工夫が試みられてきました。1つのアプローチは、macOS がネイティブにサポートしているネットワークベースのファイルシステム(例:NFSやSMB)を用い、その下に FUSE API を置く方法です。NFS の上に構築された FUSE 風 API を備えたオープンソースの概念実証ローカルサーバーがxetdata/nfsserveでホストされており、クローズドソースのプロジェクトmacOS-FUSE-tは NFS と SMB の両方を基盤とするバックエンドをサポートしています。
問題は解決? いえ、まだです……。ファイルシステムは、ファイルの読み取り・書き込み・一覧表示だけよりもずっと複雑です!ここでは、xetdata/nfsserve
の実装が依拠している古いバージョンの NFS がファイルロックをサポートしていないため、Cargo がエラーを出しています。

MacOS-FUSE-t は NFSv4 上に構築されており、実際にファイルロックをサポートします。しかし、GitHub リポジトリにはソース以外の3つのファイル(Attributions.txt、License.txt、README.md)しかなく、作成者は追加情報のない、目的が一つだけに見える疑わしいユーザー名 macos-fuse-t
のアカウントです。明らかに、ランダムなバイナリをユーザーに配布するわけにはいきません… また、オープンな Issue からは NFS/SMB ベースのアプローチにより根本的な問題があることも示唆されており、主に Apple のカーネルのバグに関連しています。
結局、私たちに残されたのは何でしょうか? 新しい創造的アプローチか、15、それとも…政治! Apple がこの10年にわたりカーネル拡張を段階的に廃止してきた結果、ユーザーレベルの API(DriverKitなど)が次々と開放され、古いファイルシステムに対する組み込みサポートも最近はユーザーランドに移行しました。同社のオープンソースの MS-DOS コードには、FSKit
と呼ばれるプライベートフレームワークへの参照がこちらにあり、これは非常に有望に思えます! 少し政治的な働きかけがあれば、FSKit
を外部開発者向けに最終化して公開してもらえる(あるいは、すでにその予定が?)可能性もあり、その場合は macOS における実行性の問題にも解決策が見えてくるかもしれません。
未解決の質問
これまで見てきたように、AI にバックグラウンドでコードを反復させるという一見シンプルな課題は、実際にはかなり複雑です。shadow workspace は、AI に対して lint を提示するという当面のニーズを満たす実装を作るための、1 週間・1 人のプロジェクトでした。今後は、実行可能性の課題も解決できるように拡張していく予定です。いくつかの未解決の問いがあります:
- カーネル拡張を作成したり FUSE API を使用したりせずに、私たちが考えている単純なプロキシフォルダを実現する別の方法はありますか?FUSE はより大きな問題(あらゆる種類のファイルシステム)を解こうとしているため、一般的な FUSE 実装では機能しないものの、私たちのフォルダプロキシには機能するような、macOS や Windows 上のあまり知られていない API が存在する可能性があるように感じます。
- Windows でのプロキシ用フォルダーのストーリーは具体的にどのようなものになりますか?WinFspのようなものがそのまま動作するのでしょうか? それとも、インストールやパフォーマンス、セキュリティ上の問題がありますか? 私は主に、macOS でフォルダープロキシを実現する方法の調査に時間を費やしました。
- もしかすると、macOS で DriverKit を使い、疑似 USB デバイスをシミュレートしてプロキシフォルダとして機能させる方法があるかもしれません。可能性は低いと思いますが、API を十分に精査していないため、不可能だと断言できるほど自信はありません。
- ネットワークレベルの独立性をどのように実現できるでしょうか。特に考慮すべき状況として、コードが3つのマイクロサービスに分かれている統合テストをAIがデバッグしたい場合があります。16 よりVMに近いアプローチを取りたくなる可能性もありますが、その場合は環境全体のセットアップやインストール済みソフトウェアの同等性を確保するために、より多くの作業が必要になります。
- ユーザー側の手間を最小限に抑えつつ、ユーザーのローカルワークスペースと同一のリモートワークスペースを作成する方法はあるか?クラウド上では、FUSEをそのまま利用でき(パフォーマンス目的で必要ならカーネルモジュールも可)、調整ごとも不要で、ユーザー側の追加メモリ消費ゼロと完全な独立性を保証できる。プライバシーへの関心が低いユーザーには有力な代替案になり得る。プロト段階のアイデアとしては、システムの観察に基づいて自動推定されるDockerコンテナ(例えば、マシン上で動作しているものを検出するスクリプトの作成と、言語モデルを用いたDockerfileの生成の組み合わせ)などが考えられる。
これらの質問について良いアイデアがあれば、arvid@anysphere.incまでメールでお知らせください。また、このような取り組みに関わりたい方は、採用中です。