diff --git a/README.md b/README.md index 6da1866..5395e52 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,8 @@ NODE_ENV=production node server/index.js サンドボックス・GPG・ssh-agent の詳細は [サンドボックス (bwrap + rootless docker)](#サンドボックス-bwrap--rootless-docker) を参照。「アプリ」と「起動モード」のどちらの項目をクリックしても、選んだ内容で即座に起動します。 +コンボ起動のロール別アプリ選択 (ワーカーA / ワーカーB / オーケストレーター) もブラウザの `localStorage` に記憶され、次回のコンボ起動の既定になります (初期値: ワーカーA・オーケストレーターが Claude Code、ワーカーB が opencode)。単発起動の「アプリ」記憶とは独立しており、コンボ起動には `defaultApp` は適用されません。 + 新規セッションの既定アプリ・サンドボックス設定は `sandbox.config.json` (下記「設定ファイル」参照) でサーバー全体の初期値を決められますが、上記モーダルで一度でも明示的に選んだ後はブラウザ側の記憶が優先されます。 **サーバーにインストールされていない CLI は選択できません**: ccserver は起動モーダル表示時にサーバー側の実行ファイル解決 (PATH・サーバーの node バイナリディレクトリ・`~/.local/bin`・アプリ別ディレクトリ) を確認し、見つからないアプリはグレーアウトされます (ツールチップ「サーバーに未インストール」)。既定アプリが未インストールの場合も、利用可能なアプリへ自動で切り替えます。何らかの経路で未インストールのアプリが指定された場合 (例: 予約プロンプトの自動再開)、サーバーは `Cannot launch: is not installed on this server (searched ...)` という明示エラーを返します。インストール/アンインストールした場合はブラウザを再読込すれば反映されます。 @@ -279,7 +281,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json | `sshAgent` | `false` | ssh-agent を転送 (上記参照)。UI で上書き可。 | | `gitBroker` | `true` | git/gh の認証情報スコープ制限 (上記参照)。 | | `forceSandbox` | `false` | `true` でサンドボックス外の起動を全面禁止。エージェント・シェルを問わず全セッションがサンドボックス強制になり、UI のサンドボックス切替は無効化されます。bwrap が無い環境 (または Windows) では起動をエラーで拒否します (`/usage` 取得の直接起動フォールバックも同様に禁止)。ホストに bwrap (bubblewrap) のインストールが必須です。 | -| `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (グループ内では claude が既定で、copilot はそもそも選択不可)。 | +| `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (コンボのロール別選択は別途ブラウザの `localStorage` に記憶され、copilot はそもそも選択不可)。 | | `showUsage` | `true` | タブバー右端の Usage ボタン (Claude Code の `/usage`) を表示するか。`false` で非表示。**claude がサーバーに無い場合は設定に関わらず自動的に非表示**になります。 | | `binds` | `[]` | 追加で見せるホストパス。各要素 `{ src, mode?, dest? }`。`mode` は `ro` (既定) か `rw`。存在しないパスはスキップ。`~` はホームに展開。`~/.ssh` と `~/.config/gh` は `gitBroker` の設定に関わらず常にブロックされます。 | | `env` | `{}` | サンドボックス内の追加環境変数 (適用順は最後 = 既定値を上書き)。例: `sshAgent: true` のときに `SSH_AUTH_SOCK` を明示指定して自動検出を上書き。 | diff --git a/client/src/components/DirectoryBrowser.jsx b/client/src/components/DirectoryBrowser.jsx index 7579cad..5da07f3 100644 --- a/client/src/components/DirectoryBrowser.jsx +++ b/client/src/components/DirectoryBrowser.jsx @@ -5,6 +5,31 @@ const LAST_DIR_KEY = 'ccserver-last-dir'; const SANDBOX_KEY = 'ccserver-sandbox-default'; const SANDBOX_OPTS_PREFIX = 'ccserver-sandbox-opts:'; const APP_KEY = 'ccserver-app-default'; +const COMBO_APPS_KEY = 'ccserver-combo-apps'; +const COMBO_ROLES = ['workerA', 'workerB', 'orchestrator']; +const COMBO_DEFAULT_APPS = { workerA: 'claude', workerB: 'opencode', orchestrator: 'claude' }; + +// Combo-mode role app picks, remembered per browser like the single-launch +// APP_KEY so the next combo launch reuses them instead of the claude/ +// opencode defaults. Only claude/opencode are valid choices; anything else +// falls back to the per-role default. +function loadComboApps() { + try { + const raw = localStorage.getItem(COMBO_APPS_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + const out = {}; + for (const role of COMBO_ROLES) { + const v = parsed[role]; + out[role] = v === 'claude' || v === 'opencode' ? v : COMBO_DEFAULT_APPS[role]; + } + return out; + } + } + } catch { /* ignore */ } + return { ...COMBO_DEFAULT_APPS }; +} // Per-directory opt-in sandbox flags (gpg / sshAgent), remembered separately // per cwd rather than as one server-wide default -- see server/sandbox.config.json's @@ -69,7 +94,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO }); const [openMenuOpen, setOpenMenuOpen] = useState(false); const [launchMode, setLaunchMode] = useState('single'); // 'single' | 'combo' - const [comboApps, setComboApps] = useState({ workerA: 'claude', workerB: 'opencode', orchestrator: 'claude' }); + const [comboApps, setComboApps] = useState(() => loadComboApps()); // Free-form per-role model identifiers; empty string = omitted (server uses // the persisted role preference, then the app default). null would mean // "explicitly the app default", which the text input doesn't produce -- an @@ -85,6 +110,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO // otherwise the user's next "起動" -- possibly for a different project -- // would silently fire a full combo spawn with the previous instructions. // One close path for every exit route so future routes can't forget. + // (The role app picks in comboApps are the exception: they are remembered + // in localStorage, see loadComboApps/chooseComboApp, and intentionally + // survive both modal closes and reloads.) const closeOpenMenu = useCallback(() => { setLaunchMode('single'); setOrchestratorInstructions(''); @@ -105,6 +133,15 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO localStorage.setItem(APP_KEY, val); }, [availableApps]); + const chooseComboApp = useCallback((role, app) => { + if (availableApps && !availableApps[app]) return; // server lacks this CLI + setComboApps((c) => { + const next = { ...c, [role]: app }; + localStorage.setItem(COMBO_APPS_KEY, JSON.stringify(next)); + return next; + }); + }, [availableApps]); + // gpg/sshAgent are remembered per directory, not globally -- reload whenever // the browser navigates to a different one. useEffect(() => { @@ -180,11 +217,16 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO // claude/opencode, so the fallback is restricted to those. const comboAvail = ['claude', 'opencode'].filter((a) => data.availableApps[a]); if (comboAvail.length > 0) { - setComboApps((c) => ({ - workerA: data.availableApps[c.workerA] ? c.workerA : comboAvail[0], - workerB: data.availableApps[c.workerB] ? c.workerB : comboAvail[0], - orchestrator: data.availableApps[c.orchestrator] ? c.orchestrator : comboAvail[0], - })); + setComboApps((c) => { + const next = { + workerA: data.availableApps[c.workerA] ? c.workerA : comboAvail[0], + workerB: data.availableApps[c.workerB] ? c.workerB : comboAvail[0], + orchestrator: data.availableApps[c.orchestrator] ? c.orchestrator : comboAvail[0], + }; + // Write the corrected picks through so a reload keeps them. + localStorage.setItem(COMBO_APPS_KEY, JSON.stringify(next)); + return next; + }); } } }).catch(() => {}); @@ -546,7 +588,7 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO