1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code を複数セッション同時管理する Electron オーケストレーター「タクト」を作った全記録

1
Last updated at Posted at 2026-10-01

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 エラーが繰り返しダイアログを出しました。

対策:

  1. process.stdout / process.stderr の error イベントで EPIPE を無視
  2. process.on('uncaughtException') で EPIPE はダイアログなしでログだけ
  3. 通常起動は 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 を使ったオーケストレーターを作る際の参考になれば幸いです。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?