Claude Code を複数セッション同時管理する Electron オーケストレーター「タクト」を作った全記録
はじめに
Claude Code(Anthropic の CLI エージェント)を複数プロジェクトにわたって同時に走らせ、結果を 1 箇所で管理したい——そんな欲求から生まれたのが タクト(TAKT) です。
Cowork(窓口) → inbox/*.md → 頭脳(claude -p セッション)→ ワーカー(claude --bg セッション群)→ claude agents --json --all でポーリング → 台帳に反映、というパイプラインを Electron + TypeScript で実装しました。
本記事は、Phase 0(スキャフォールド+PoC)から Phase 1(Electron HUD・複数プロジェクト・スケジューラ・自己改善ループ)まで、実際にハマったポイントを中心にまとめた実装ログです。
やったこと
Phase 0:パイプラインの実証
最初の目標は「claude --bg でワーカーを 2 体派遣し、監視し、回収し、台帳を更新する」という一気通貫を scripts/poc.ts だけで通すことでした。
役割分担
Cowork(窓口)
└─ inbox/*.md に依頼ファイルを置く
↓
頭脳(claude -p)
├─ state/tasks.json にタスクを切る
└─ claude --bg --name <task-id> でワーカーを派遣
↓
ワーカー(claude --bg)
└─ 対象リポジトリの worktree で作業
↓
claude agents --json でポーリング
↓
claude logs / Stop hook で結果回収
↓
state/STATE.md を更新
台帳設計:「忘れても困らない」
すべての記憶を台帳(state/STATE.md と state/projects/<key>/STATE.md)に書き出し、頭脳もワーカーも起動時に読み、終了時に書く設計にしました。--resume に頼った前提は捨て、新規セッションを毎回起動するため 台帳が唯一の真実 です。
Phase 1:Electron HUD
Phase 1 では Electron でダッシュボードを実装しました。中央に惑星(頭脳)、周囲に衛星(ワーカー)が浮かぶ HUD 風レイアウトです。
主な機能
- プロジェクト単位のチップ切り替え(絞り込みはタブ・衛星・依頼・質問・台帳・報告すべてに波及)
- Waiting on you:数字キーで即答できる質問キュー
- 「書く」タブ:自由記述 → 頭脳が自動で依頼 / 不具合 / 決定 / 回答に振り分け
- スケジューラ(毎日・毎週・1 回きり、枠ガード付き)
- 残作業ボード(B-xxx、状態は 調査済み / 実装済み(未反映)/ 反映済み / 確認済み)
- 通知欄(トースト廃止、ベル+未読数)
-
say -v Kyokoによる読み上げ(SpeechQueue で重複防止)
ハマったポイント
1. Agent SDK はサブスク枠で使えない
当初は @anthropic-ai/claude-agent-sdk を使って「頭脳」を実装しました。ところが公式ドキュメントに次の注記があります。
事前承認がない限り、Agent SDK で作ったアプリに claude.ai ログイン(サブスク枠)を使わせることは認めない。API キー認証を使え。
Claude Max で完結させたい設計なので、頭脳を claude -p --output-format json --json-schema の子プロセス呼び出しに書き換えました。
// scripts/lib/brain.ts(抜粋)
async function claudeP<T>(schema: object, prompt: string, opts: BrainOptions): Promise<T> {
const args = [
"-p",
"--output-format", "json",
"--json-schema", JSON.stringify(schema),
"--setting-sources", "project,local",
"--model", opts.model ?? "claude-opus-4-5",
"--permission-mode", "default",
];
// 構造化出力を stdin 経由で受け取る
const result = await spawnWithStdin("claude", args, prompt);
return result.structured_output as T;
}
副次効果として、--setting-sources project,local の指定で起動トークンが 85,188 → 4,124 トークン(約 20 分の 1)に激減しました。
2. macOS の NFC/NFD 問題
claude --bg は ~/.claude.json の projects[path].hasTrustDialogAccepted を参照して Workspace の信頼を確認します。ところが macOS の process.cwd() は NFD(濁点分離)で返すのに、キーが NFC(合成済み)で保存されていたため、信頼チェックが常に false になりました。
// scripts/lib/trust.ts
import { normalize } from "path";
export function isTrusted(dir: string): boolean {
const config = readClaudeJson();
const nfc = dir.normalize("NFC");
const nfd = dir.normalize("NFD");
const projects = config.projects ?? {};
return (
projects[nfc]?.hasTrustDialogAccepted === true ||
projects[nfd]?.hasTrustDialogAccepted === true
);
}
3. --allowedTools はカンマ結合ではなく個別引数
# ❌ これは後ろのルールが効かない
claude -p --allowedTools "Edit(file.md),Read"
# ✅ 個別引数にする
claude -p --allowedTools "Edit(file.md)" --allowedTools "Read"
加えて、Edit(//絶対パス) ルールはバイト列で比較されるため、NFC と NFD の両方のパスを渡す必要があります。
4. worktree の名前はセッション ID ではなくスラッグ
ドキュメントには worktree 名が <session-id> 形式と読めましたが、実際は Claude が付けたスラッグ(例:clamp、readme-ja)になっていました。そのため claude rm するときは claude agents --json の id フィールドではなく name から worktree パスを特定する必要があります。
5. claude rm は未 push コミットがあると拒否される
統合前に claude rm を呼ぶと失敗します。出力の --discard-unpushed <commit>@<wt-id> を拾って 2 回目で削除する処理が必要でした。
async function rmWorker(id: string): Promise<void> {
let result = await run("claude", ["rm", id]);
if (result.exitCode !== 0 && result.stderr.includes("--discard-unpushed")) {
const match = result.stderr.match(/--discard-unpushed (\S+)/);
if (match) {
await run("claude", ["rm", id, "--discard-unpushed", match[1]]);
}
}
}
6. EPIPE でダイアログが繰り返し出る
electron:dev 起動後にターミナルを閉じると、パイプ先が消えて EPIPE エラーが繰り返しダイアログを出しました。
対策:
-
process.stdout/process.stderrのerrorイベントでEPIPEを無視 -
process.on('uncaughtException')でEPIPEはダイアログなしでログだけ - 通常起動は
detached + stdio: ignore + unref()でターミナルから切り離す
// electron/main.ts(抜粋)
process.stdout.on("error", (e) => { if ((e as any).code !== "EPIPE") throw e; });
process.on("uncaughtException", (e) => {
if ((e as any).code === "EPIPE") { jlog("warn", "EPIPE ignored"); return; }
dialog.showErrorBox("Uncaught Exception", String(e));
});
7. Topbar のドラッグ領域がクリックを奪う
titleBarStyle: "hiddenInset" + .topbar { -webkit-app-region: drag } の組み合わせで、topbar 内のチップやボタンのクリックを macOS が奪ってしまいました。
/* ❌ topbar 全体をドラッグ領域にするとボタンが効かない */
.topbar { -webkit-app-region: drag; }
/* ✅ ブランド部分だけをドラッグ領域にする */
.brand { -webkit-app-region: drag; }
.projs, .proj, .pills, .topbar button { -webkit-app-region: no-drag; }
自動確認(diagnose)は webContents.sendInputEvent で本物のクリックを送るようにしました。IPC 経由で呼ぶと本物のクリックの検証にならないため、座標は renderer から getBoundingClientRect で取得します。
8. 枠の計測:裏のプローブが古い値を書き戻していた
5 時間枠を「約 n%」で表示するため、status line JSON を吐く裏セッション(takt-usage-probe)を常駐させました。ところが、このプローブは何もやりとりしないため古い値を持ち続け、使っているセッションが書いた新しい値を上書きしていました。
修正:同じ窓の中では大きい方(=より使っている方)を残すルールを state/usage.json の書き込み側に入れました。
9. --setting-sources project,local と --add-dir の組み合わせ
ドキュメントには --add-dir X でスキルが読まれると書いてありましたが、実際には X/.claude/skills/ 配下 が対象でした。--add-dir ~/.claude/skills は無意味で、~/.claude/skills/<name> を直接指定してもスキルとして認識されません。
state/projects/<key>/skilldir/
└─ .claude/
└─ skills/
└─ jarvis-report -> ~/.claude/skills/jarvis-report (symlink)
このディレクトリ構造を作り --add-dir state/projects/<key>/skilldir で渡すことで、対象リポジトリを汚さずにスキルを注入できます。
10. 診断が本物のフォルダに触れる問題
自動確認(diagnose)用に notes や inbox の写しを作ったつもりが、ファイル一覧の参照先が本物のフォルダを向いていたため、診断の片付け処理が本物のメモを削除しました。
修正:診断起動時に state/ reports/ notes/ inbox/ を丸ごと一時フォルダへコピーし、環境変数でそこを指す。本物のパスに書き込もうとしたら例外にする。
// electron/main.ts(diagnose 起動部抜粋)
const tmpDir = mkdtempSync(join(tmpdir(), "takt-diag-"));
cpSync(DATA_ROOT, tmpDir, { recursive: true });
env["TAKT_STATE_DIR"] = tmpDir;
// 本物への書き込みを検知する guard も同時に有効化
env["TAKT_DIAG_REAL_GUARD"] = "1";
学び
「忘れても困らない」設計が効く
セッションが途中で死んでも、台帳さえ残っていれば再開できます。--resume に依存した状態管理は、コンテキスト肥大や OAuth 切れのリスクがあり、台帳ファースト設計の方が堅牢でした。実測でも、resume 方式(66k + 313k トークン)vs 台帳ファースト(22k + 176k トークン)で約 4 分の 1 に削減できました。
--setting-sources project,local は効果大
user スコープの設定(3,000 件超の permissions.allow など)を読まないだけで、起動コストが 85k → 4k トークンになりました。ただしスキルへのアクセスは user スコープ経由だったため、代替手段(--add-dir + symlink)が必要でした。
自動確認は「本物のクリック」で
element.click() による直接操作は、「上に何かが被っている」「描き直しで消える」といった英紀さんが実際に困る種類の不具合を素通りさせます。webContents.sendInputEvent でマウスイベントを座標指定で送る方式が、実使用に最も近い確認になります。
クォータ表示は「実測ベースの増分」で
トークン数をそのまま表示するのではなく、/usage の % 増分から換算した「今日の枠 約 n%(5h)/ 週 約 m%」の表示が最も分かりやすかったです。裏プローブの古い値上書き問題など、実測の罠も多いため、resetsAt と measuredAt を常に一緒に記録することが重要です。
個人の感想
1 日でこれだけの機能を詰め込めたのは、Claude Code の --bg + agents --json + hooks の組み合わせが思ったより強力だったからです。特に hooks を --settings 経由で渡せる(対象リポジトリを汚さずに済む)のは設計を大幅に楽にしてくれました。一方で、ドキュメントと実挙動の差異(worktree 名・--allowedTools の引数形式・スキルの探索パスなど)は多く、実際に動かして確かめるしかない部分が多いと感じました。
まとめ
| フェーズ | 主な成果 |
|---|---|
| Phase 0 |
claude --bg × hooks × 台帳ファースト設計の実証、NFC/NFD 問題の発見 |
| Phase 1 前半 | Electron HUD・複数プロジェクト対応・枠の表示・「書く」タブ自動振り分け |
| Phase 1 後半 | スケジューラ・自己改善ループ・決裁機能・並行化・診断の隔離 |
コード全体は現在非公開ですが、本記事で紹介した手法(台帳ファースト設計・--settings 経由の hooks・スキルの symlink 注入・本物クリックによる自動確認)は、Claude Code を使ったオーケストレーターを作る際の参考になれば幸いです。