Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ NODE_ENV=production node server/index.js

新規セッションの既定アプリ・サンドボックス設定は `sandbox.config.json` (下記「設定ファイル」参照) でサーバー全体の初期値を決められますが、上記モーダルで一度でも明示的に選んだ後はブラウザ側の記憶が優先されます。

**サーバーにインストールされていない CLI は選択できません**: ccserver は起動モーダル表示時にサーバー側の実行ファイル解決 (PATH・サーバーの node バイナリディレクトリ・`~/.local/bin`・アプリ別ディレクトリ) を確認し、見つからないアプリはグレーアウトされます (ツールチップ「サーバーに未インストール」)。既定アプリが未インストールの場合も、利用可能なアプリへ自動で切り替えます。何らかの経路で未インストールのアプリが指定された場合 (例: 予約プロンプトの自動再開)、サーバーは `Cannot launch: <app> is not installed on this server (searched ...)` という明示エラーを返します。インストール/アンインストールした場合はブラウザを再読込すれば反映されます。

opencode を選んだ場合の挙動の違い:

- **クリップボード同期 (OSC 52)**: opencode がターミナルに書き込む OSC 52 シーケンスをブラウザが解釈し、システムクリップボードへ反映します (xterm.js は OSC 52 を無視するため、ccserver 側で処理)。
Expand Down Expand Up @@ -156,6 +158,7 @@ GitHub Copilot を選んだ場合:
- 裏側では `claude --ax-screen-reader` を短時間起動して `/usage` の描画をパースし、結果を約 2 分キャッシュします (`/usage` の閲覧自体は API を消費しません)。「更新」ボタンで即時に再取得できます。
- bwrap がある環境では、**Claude の設定だけを見せる最小サンドボックス** (docker/gpg/ssh なし) で起動します。無ければ claude を直接起動します。
- API: `GET /api/usage` (`?force=1` で強制再取得)。サーバー起動時にキャッシュを 1 度ウォームします。
- ボタンは設定ファイルの `showUsage: false` で非表示にできます。さらに **claude がサーバーにインストールされていない環境では、設定に関わらず自動的に非表示**になります (この場合 `GET /api/usage` は `claude is not installed on this server` を返します)。

### コンボ起動: オーケストレーターが `send_input` でワーカーに指示するときの注意

Expand Down Expand Up @@ -259,6 +262,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json
"gitBroker": true,
"forceSandbox": false,
"defaultApp": "claude",
"showUsage": true,
"notify": {
"discordWebhook": "",
"subscriptions": []
Expand All @@ -276,6 +280,7 @@ cp server/sandbox.config.example.json server/sandbox.config.json
| `gitBroker` | `true` | git/gh の認証情報スコープ制限 (上記参照)。 |
| `forceSandbox` | `false` | `true` でサンドボックス外の起動を全面禁止。エージェント・シェルを問わず全セッションがサンドボックス強制になり、UI のサンドボックス切替は無効化されます。bwrap が無い環境 (または Windows) では起動をエラーで拒否します (`/usage` 取得の直接起動フォールバックも同様に禁止)。ホストに bwrap (bubblewrap) のインストールが必須です。 |
| `defaultApp` | `"claude"` | 新規セッションの既定エージェント (`"claude"`、`"opencode"`、`"copilot"`)。UI で一度明示的に選んだ後はブラウザの記憶が優先され、この値は初回表示時の見た目とサーバー側フォールバック (予約プロンプトの自動再開など、クライアントが `app` を指定しない経路) にのみ使われます。**コンボ起動のメンバーには適用されません** (グループ内では claude が既定で、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` を明示指定して自動検出を上書き。 |
| `claudeBin` | 自動検出 | claude/opencode/copilot の起動方法。`claude` を PATH から解決し、ラッパー (例: `/usr/bin/claude` → `/opt/claude-code/bin/claude`) の場合は実体のインストール先を辿ってサンドボックスへ自動的に公開します。opencode は PATH に加えて `~/.opencode/bin` も自動探索。copilot は PATH (SANDBOX_PATH) で自動解決されます (通常 `~/.local/bin/copilot`)。自動検出で外れる場所にある場合や特定ビルドに固定したい場合のみ絶対パスで指定 (環境変数 `CCSERVER_CLAUDE_BIN` が優先。copilot に個別の bin 設定はありません)。 |
Expand Down
25 changes: 22 additions & 3 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export default function App() {
);
const pendingOpenRef = useRef(null);
const { enabled: notifyEnabled, permission: notifyPermission, toggle: toggleNotify, notify } = useNotifications();
// Server-side facts from /api/dirs/home: whether the Usage button is
// enabled (sandbox.config.json's "showUsage") and which agent CLIs are
// installed here (availableApps). Usage is only meaningful when claude
// exists, so a missing claude hides the button regardless of showUsage.
const [usagePrefs, setUsagePrefs] = useState({ showUsage: true, availableApps: null });

useEffect(() => {
applyThemeCss(themeId);
Expand All @@ -47,7 +52,14 @@ export default function App() {
authFetch('/api/dirs/home')
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data?.hostname) document.title = `${data.hostname} ccserver`;
if (!data) return;
if (data.hostname) document.title = `${data.hostname} ccserver`;
// Absent keys (older server / default config) keep the button shown
// and the app picker unrestricted.
setUsagePrefs({
showUsage: data.showUsage !== false,
availableApps: data.availableApps || null,
});
})
.catch(() => {});
}, []);
Expand Down Expand Up @@ -315,9 +327,16 @@ export default function App() {
const activeTab = tabs.find((t) => t.id === activeTabId);
// Usage (Claude spend) is only meaningful for claude sessions; hide it for
// opencode/copilot terminals and for a group tab whose active sub-tab is
// opencode (copilot never appears in groups).
// opencode (copilot never appears in groups). The server can also hide it:
// sandbox.config.json's "showUsage": false, or claude not being installed
// at all (the /usage capture would never succeed).
// `availableApps` null/absent (fetch pending or failed, older server) means
// "unknown", not "claude missing" -- the button stays shown in that case and
// is only auto-hidden when the server actually reports claude absent.
const usageHidden = (activeTab?.type === 'terminal' && (activeTab.app === 'opencode' || activeTab.app === 'copilot'))
|| (activeTab?.type === 'group' && groupActiveApp === 'opencode');
|| (activeTab?.type === 'group' && groupActiveApp === 'opencode')
|| !usagePrefs.showUsage
|| (usagePrefs.availableApps && !usagePrefs.availableApps.claude);

return (
<div className="app">
Expand Down
63 changes: 53 additions & 10 deletions client/src/components/DirectoryBrowser.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
// the sandbox toggle is overridden -- every launch is sandboxed and the
// "通常起動" choice is disabled. Set from /api/dirs/home.
const [forceSandbox, setForceSandbox] = useState(false);
// Which agent CLIs the server can actually launch ({ claude, opencode,
// copilot } booleans), from /api/dirs/home. null until the fetch resolves;
// while null every picker entry stays enabled (old-server fallback).
const [availableApps, setAvailableApps] = useState(null);
// 'claude' until the server's configured default (sandbox.config.json's
// "defaultApp") arrives via /api/dirs/home, or the user picks explicitly.
const [appDefault, setAppDefault] = useState(() => {
Expand Down Expand Up @@ -96,9 +100,10 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
}, [forceSandbox]);

const chooseApp = useCallback((val) => {
if (availableApps && !availableApps[val]) return; // server lacks this CLI
setAppDefault(val);
localStorage.setItem(APP_KEY, val);
}, []);
}, [availableApps]);

// gpg/sshAgent are remembered per directory, not globally -- reload whenever
// the browser navigates to a different one.
Expand Down Expand Up @@ -150,6 +155,38 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
setForceSandbox(true);
setSandboxDefault(true);
}
// Server-side install detection: grey out picker entries for CLIs that
// don't exist here, and correct a stale default (localStorage
// ccserver-app-default, or the server's defaultApp) that points at an
// uninstalled app -- the launch button label and modal checkmark must
// never advertise an app that cannot start.
if (data.availableApps) {
setAvailableApps(data.availableApps);
const avail = ['claude', 'opencode', 'copilot'].filter((a) => data.availableApps[a]);
// The server's defaultApp seeding above runs in the same effect tick,
// so appDefault is still the stale pre-seeding value here -- evaluate
// the effective default (server's when the browser hasn't chosen yet,
// else the remembered one) before testing availability.
const effectiveDefault = ['claude', 'opencode', 'copilot'].includes(data.defaultApp) && !localStorage.getItem(APP_KEY)
? data.defaultApp
: appDefault;
if (avail.length > 0 && !data.availableApps[effectiveDefault]) {
setAppDefault(avail[0]);
}
// Same rule for the combo modal's role selections: workerA and the
// orchestrator start as claude, workerB as opencode -- a role whose
// default points at a missing CLI must not stay selected-active (the
// launch would be refused server-side). Combo only offers
// 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],
}));
}
}
}).catch(() => {});
}, []);

Expand Down Expand Up @@ -432,22 +469,25 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
<>
<div className="open-menu-label">アプリ</div>
<div
className="open-menu-item"
className={`open-menu-item${availableApps && !availableApps.claude ? ' open-menu-item-disabled' : ''}`}
onClick={() => chooseApp('claude')}
title={availableApps && !availableApps.claude ? 'サーバーに未インストール' : ''}
>
<span className="open-menu-check">{appDefault === 'claude' ? '✓' : ''}</span>
Claude Code
</div>
<div
className="open-menu-item"
className={`open-menu-item${availableApps && !availableApps.opencode ? ' open-menu-item-disabled' : ''}`}
onClick={() => chooseApp('opencode')}
title={availableApps && !availableApps.opencode ? 'サーバーに未インストール' : ''}
>
<span className="open-menu-check">{appDefault === 'opencode' ? '✓' : ''}</span>
opencode
</div>
<div
className="open-menu-item"
className={`open-menu-item${availableApps && !availableApps.copilot ? ' open-menu-item-disabled' : ''}`}
onClick={() => chooseApp('copilot')}
title={availableApps && !availableApps.copilot ? 'サーバーに未インストール' : ''}
>
<span className="open-menu-check">{appDefault === 'copilot' ? '✓' : ''}</span>
GitHub Copilot
Expand Down Expand Up @@ -505,8 +545,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
{['claude', 'opencode'].map((app) => (
<button
key={app}
className={`open-menu-app-btn${comboApps.workerA === app ? ' active' : ''}`}
onClick={() => setComboApps((c) => ({ ...c, workerA: app }))}
className={`open-menu-app-btn${comboApps.workerA === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, workerA: app })); }}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
</button>
Expand All @@ -529,8 +570,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
{['claude', 'opencode'].map((app) => (
<button
key={app}
className={`open-menu-app-btn${comboApps.workerB === app ? ' active' : ''}`}
onClick={() => setComboApps((c) => ({ ...c, workerB: app }))}
className={`open-menu-app-btn${comboApps.workerB === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, workerB: app })); }}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
</button>
Expand Down Expand Up @@ -612,8 +654,9 @@ export default function DirectoryBrowser({ onOpen, onOpenShell, onOpenCombo, onO
{['claude', 'opencode'].map((app) => (
<button
key={app}
className={`open-menu-app-btn${comboApps.orchestrator === app ? ' active' : ''}`}
onClick={() => setComboApps((c) => ({ ...c, orchestrator: app }))}
className={`open-menu-app-btn${comboApps.orchestrator === app ? ' active' : ''}${availableApps && !availableApps[app] ? ' open-menu-item-disabled' : ''}`}
onClick={() => { if (!availableApps || availableApps[app]) setComboApps((c) => ({ ...c, orchestrator: app })); }}
title={availableApps && !availableApps[app] ? 'サーバーに未インストール' : ''}
>
{app === 'claude' ? 'Claude Code' : 'opencode'}
</button>
Expand Down
10 changes: 7 additions & 3 deletions server/routes/dirs.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { readdir, mkdir, stat } from 'node:fs/promises';
import { join, resolve, basename } from 'node:path';
import { homedir } from 'node:os';
import { loadSandboxConfig } from '../ws/sandbox.js';
import { loadSandboxConfig, installedApps } from '../ws/sandbox.js';
import { resolvedHostname } from '../ws/notify.js';

export async function dirsRoute(fastify, opts) {
fastify.get('/dirs/home', async () => {
const { defaultApp, forceSandbox } = loadSandboxConfig();
const { defaultApp, forceSandbox, showUsage } = loadSandboxConfig();
// hostname for the browser tab title ("<host> ccserver"): the same
// resolution the notify footer uses, so the tab matches _from: <host>.
// Extra field, so existing clients are unaffected.
return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname() };
// showUsage / availableApps: the client's Usage button visibility and the
// launch modal's app picker both need server-side facts -- whether the
// Usage button is enabled in config, and which agent CLIs are installed
// here. Both are extra fields, so existing clients are unaffected.
return { home: homedir(), defaultApp, forceSandbox, hostname: resolvedHostname(), showUsage, availableApps: installedApps() };
});

fastify.get('/dirs', async (request, reply) => {
Expand Down
3 changes: 3 additions & 0 deletions server/sandbox.config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
"//defaultApp": "Which agent CLI a new session launches when the client doesn't request one (legacy scheduled prompts, or a browser tab that hasn't picked one yet). 'claude' (default), 'opencode' or 'copilot'. The launch UI's own choice (remembered per browser) always wins once made; this only seeds that choice on first load and covers server-side fallback paths. Combo (group) members never use it — groups default to claude and refuse copilot.",
"defaultApp": "claude",

"//showUsage": "true (default): the client shows the top-bar Usage button (Claude Code /usage spend). false: hide it entirely. The button is also hidden automatically when claude is not installed on this server (the /usage capture could never succeed).",
"showUsage": true,

"//notify": "ccserver-notify MCP (通知用, 下記 README)。discordWebhook: https:// の Discord webhook URL (空文字で無効)。設定するとエージェントの notify ツールが Discord へ通知を送れる。CCSERVER_DISCORD_WEBHOOK 環境変数で上書き可。subscriptions: 初期購読 (bootstrap)。https:// の webhook URL の配列。購読ゼロかつ discordWebhook 未設定なら MCP は注入されない。hostname: 通知フッターのホスト名を固定したい場合のみ (未設定なら OS の hostname、CCSERVER_HOSTNAME 環境変数が最優先)。attribution: false にすると通知ペイロード末尾の \"_from: host · project · group · session\" フッターを丸ごと無効化 (既定 true)。",
"notify": {
"discordWebhook": "",
Expand Down
11 changes: 10 additions & 1 deletion server/usage.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,16 @@ function looksReady(parsed) {

function capture() {
return new Promise((resolve) => {
let command = resolveClaude().command;
// claude not installed on this host (or claudeBin pointing at a missing
// path): a pty.spawn would just fail with execvp/ENOENT. Report the real
// cause up front -- this also backs the client's automatic Usage-button
// hiding (availableApps.claude === false via /dirs/home).
const resolvedClaude = resolveClaude();
if (resolvedClaude.found === false) {
resolve({ error: 'claude is not installed on this server' });
return;
}
let command = resolvedClaude.command;
let args = ['--ax-screen-reader'];
let spawnCwd = homedir();
let sandboxed = false;
Expand Down
Loading
Loading