📚 Copilot Cowork 開発シリーズ
- なぜ作るのか — 調査と設計判断
- Electron + React + SDK で土台を組む ← 今ここ
- C# で業務向けに拡張する設計
- 途中状態の見せ方と入れ子実行
今回は、Copilot Cowork の実装側の話です。
最初に正直に書いておくと、この記事は「ゼロから 2 時間で MVP を作るチュートリアル」ではありません。2026 年 3 月 11 日時点で、実際にここまで組めた内容をベースに、Electron と Copilot SDK をどうつないだかを整理する記事です。
📋 この記事の前提
- TypeScript / React の基本文法がわかる
- Electron の main process / renderer process の区別がなんとなく分かる
- Node.js と npm がインストール済み
- GitHub Copilot SDK(Technical Preview)の概要を知っている(前回記事で解説)
- 前回: なぜ Copilot SDK を軸にするか
現時点で動いているのは次の機能です。
- セッションの作成、再開、削除
- ストリーミング応答と reasoning の表示
- tool trace の可視化
- permission dialog と ask_user dialog
- workspace 選択と添付ファイル
- モデル切り替え
- custom agent の選択
- settings の保存と SQLite ベースの管理
まだ未実装なのは、Node 側の custom tool registry と .NET worker 連携です。つまり、UI と Copilot runtime の接続はできたが、業務向けの独自 backend はこれからという段階です。
先に完成形の構造
いまの構造はかなりシンプルです。
React renderer
↓ preload
Electron IPC
↓
CopilotClientManager
↓
@github/copilot-sdk
↓
Copilot CLI harness
ポイントは、Copilot SDK を renderer に置かないことです。
SDK やセッション管理、permission の処理は Electron の main process に集めています。renderer はあくまで UI に徹し、preload 越しに必要な API だけを呼びます。
この分離にしておくと、
- renderer 側は React の状態管理に集中できる
- SDK のライフサイクルが 1 か所にまとまる
- permission や添付ファイルの扱いを main 側で制御できる
という利点があります。
セッション管理は main process に寄せる
Copilot Cowork では、セッション作成時に settings と workspace をまとめて組み立ててから SDK に渡しています。
実際の流れはこうです。
ipcMain.handle(IPC_CHANNELS.CREATE_SESSION, async (_event, name: string, workspacePath?: string) => {
const settingsState = loadSettingsState(db, workspacePath);
const targetWorkspacePath = workspacePath ?? settingsState.selectedWorkspacePath;
const sessionSettings: AppSettings = {
...settingsState.effectiveSettings,
workingDirectory: targetWorkspacePath ?? settingsState.effectiveSettings.workingDirectory,
};
const sdkSessionId = await copilot.createSession(sessionSettings);
const session: Session = {
id: sdkSessionId,
name: name || 'New Session',
workspacePath: targetWorkspacePath ?? sessionSettings.workingDirectory,
model: sessionSettings.model,
apiProvider: sessionSettings.apiProvider,
createdAt: Date.now(),
updatedAt: Date.now(),
messages: [],
};
db.saveSession(...);
db.saveSessionSettings(...);
return session;
});
重要なのは、UI の見た目の状態だけでセッションを持たないことです。
セッションは SDK とアプリ内 DB の両方にまたがるので、
- どの workspace を見ているか
- どの model を使っているか
- どの harness 設定が効いているか
を main 側で確定させてから作る必要があります。
ストリーミングはイベントとして流す
Copilot Cowork では、streaming delta、reasoning delta、tool call、tool result を main から renderer へイベントとして流しています。
copilot.setStreamDeltaHandler((payload) => {
getWindow()?.webContents.send(IPC_CHANNELS.STREAM_DELTA, {
...payload,
delta: sanitizeText(payload.delta, recentResolvedAttachmentsBySession.get(payload.sessionId)),
});
});
copilot.setToolCallHandler((payload) => {
getWindow()?.webContents.send(
IPC_CHANNELS.TOOL_CALL,
sanitizeToolCallPayload(payload, recentResolvedAttachmentsBySession),
);
});
renderer 側では、今見ている sessionId に一致するイベントだけ拾って、画面を更新します。
const unsubDelta = window.copilotAPI.onStreamDelta((payload) => {
if (payload.sessionId !== session.id) {
return;
}
setStreamingContent((prev) => prev + payload.delta);
});
const unsubReasoning = window.copilotAPI.onReasoningDelta((payload) => {
if (payload.sessionId !== session.id) {
return;
}
setReasoningContent((prev) => prev + payload.delta);
});
この形にすると、チャット本文だけでなく、reasoning や tool trace も横に出せます。CLI で一列に流すより、何が起きたかを分解して見せやすいのが Electron の利点です。
settings は global と workspace を分けた
最初は settings を 1 枚の JSON で持てばいいと思っていました。
ただ、実際には以下が混ざると運用しづらいです。
- どの workspace でも共通で使いたい設定
- 特定の workspace だけで変えたい tool policy や skills
そこで、Copilot Cowork では settings を
- general
- global harness
- workspace harness
に分けています。
UI でも scope を切り替えて編集できるようにしました。
<select
value={harnessScope}
onChange={(event) => setHarnessScope(event.target.value as SettingsScope)}
>
<option value="global">Global</option>
<option value="workspace" disabled={!isWorkspaceScopeAvailable}>Workspace</option>
</select>
この分離を入れたことで、tool policy、skills、custom agents、MCP の設定がだいぶ整理されました。ここは CLI 単体より、デスクトップ UI にする価値が出やすい部分だと思っています。
Electron で実際にハマったところ
ここが一番記事っぽいところです。素直に進んだわけではありません。
1. Electron の process.execPath は Node の代わりにならない
Copilot CLI を Electron 内から扱うとき、process.execPath は Electron 本体を指します。Node バイナリ前提の処理にそのまま使えません。
そのため、main 側では system の Node を解決する処理を入れています。
function findSystemNode(): string {
try {
const result = execSync(process.platform === 'win32' ? 'where.exe node' : 'which node', {
encoding: 'utf8',
timeout: 5000,
});
const nodePath = result.trim().split('\n')[0].trim();
if (existsSync(nodePath)) return nodePath;
} catch {}
return process.execPath;
}
この差を知らないまま進めると、ローカルでは動いても packaged app で崩れやすいです。
2. SDK 側の import を postinstall で補正した
現行バージョンでは、Electron 環境で vscode-jsonrpc/node の import がそのままだと崩れるケースがありました。そこで postinstall でパッチを当てています。
const sessionFilePath = resolve('node_modules', '@github', 'copilot-sdk', 'dist', 'session.js');
const brokenImport = 'from "vscode-jsonrpc/node";';
const fixedImport = 'from "vscode-jsonrpc/node.js";';
本来は upstream 側で直るのが理想ですが、Technical Preview を触る以上、こういうワークアラウンドはある程度受け入れる必要があります。
3. 添付ファイルは sanitize と session 切り替えが厄介
添付ファイルを渡せるようにすると、streaming や tool result にローカルパスが混ざりやすくなります。しかも session を切り替えた直後に遅れてイベントが飛ぶことがあります。
そのため、Copilot Cowork では session 単位で recent attachments を持ち、表示前に redaction をかけています。
ここは CLI では気になりにくいですが、UI で trace を見せると急に重要になるポイントでした。
trace panel を入れたのは正解だった
地味ですが、個人的にかなり効いたのが trace panel です。
- どの tool が動いたか
- どれくらい時間がかかったか
- session 内で何が起きたか
を別パネルで見られるようにしました。
CLI のログを追いかけるより、セッションごとの activity を視覚的に追えるので、デバッグ効率がかなり上がります。
テストも最初から入れている
いまの段階で Vitest ベースのテストを入れています。
- IPC handlers
- settings helpers
- session history
- permission manager
- chat panel
特に settings 周りは UI を後から足しやすいように、シリアライズとセクション変換をテストで固定しておくのが大事でした。
AI まわりのアプリでも、壊れ方の多くは LLM ではなく設定と状態管理の境界で起きます。
ここまで作って感じたこと
Copilot SDK を使うと、AI の中核部分はかなり任せられます。
その代わり、アプリ側で本当に効いてくるのは次の部分です。
- どこまでを main process で持つか
- settings をどう分けるか
- ストリーミングと trace をどう見せるか
- ローカルパスや権限をどう安全に扱うか
つまり、難しいのは「AI を呼ぶこと」よりも、業務で使って壊れにくい器にすることでした。
まとめ
- Copilot SDK は Electron の main process に寄せる方が整理しやすい
- renderer は preload 越しに API を呼ぶだけにすると役割が明確になる
- ストリーミング、reasoning、tool trace を分けて扱うと UI の価値が出る
- settings は global と workspace で分けた方が運用しやすい
- Technical Preview を触るなら、SDK の癖を吸収する小さなワークアラウンドは前提になる
次回は、Copilot Cowork を C# でどう業務向けに拡張するか、設計の境界を整理します。
よくある質問
Q: Electron の代わりに Tauri を使うことはできますか?
A: 技術的には可能ですが、Copilot SDK が Node.js 前提のため、Electron の方が接続がシンプルです。Tauri の場合はサイドカープロセスとして Node を動かす必要があり、構成が一段複雑になります。
Q: なぜ renderer ではなく main process に SDK を置くのですか?
A: SDK はセッション管理、permission 処理、ファイルアクセスなど OS レベルの操作を伴います。これらを renderer(ブラウザ相当)に置くとセキュリティと状態管理が複雑になるため、main process に集約しています。
Q: Copilot SDK を使うのに GitHub Copilot Pro は必要ですか?
A: はい。SDK のランタイムは GitHub Copilot の認証を必要とします。Technical Preview の段階では Pro 以上のサブスクリプションが前提です。
この記事が参考になったら「いいね」で応援お願いします!
実装の迷いどころはできるだけ正直に残していくつもりです。
📝 この記事は Zenn で最初に公開されました。
最新版はZennをご覧ください。