Add read-only runtime inventory settings (#130)

* add gated codex goals support

* sync README SDK versions

* bump claude agent sdk

* add codex goals settings toggle

* reuse status dashboard message and simplify settings actions

* refine settings page UX

* fix settings nav hash routing

* add dashboard UX verification

* refine dashboard settings and inbox UX

* remove inbox top-level navigation

* remove dashboard health top-level navigation

* fix: allow runtime image attachment paths

* feat: configure attachment allowlist dirs

* fix: clean duplicate dashboard status messages

* fix: poll dashboard duplicate cleanup between status updates

* fix: delete dashboard duplicates on create

* Refine settings IA with tabbed sections

* Add read-only runtime inventory settings
This commit is contained in:
Eyejoker
2026-05-04 02:13:42 +09:00
committed by GitHub
parent c7d4bf82d7
commit 2da6052eff
11 changed files with 890 additions and 1 deletions

View File

@@ -0,0 +1,130 @@
.runtime-inventory {
display: grid;
gap: 18px;
}
.runtime-summary-card,
.runtime-agent-card,
.runtime-skill-card {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 20px;
background: rgba(15, 23, 42, 0.48);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.runtime-summary-card {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 18px;
}
.runtime-summary-card > div {
display: grid;
gap: 6px;
min-width: 0;
}
.runtime-summary-card strong {
color: var(--text);
font-size: 1rem;
}
.runtime-summary-card code,
.runtime-path-row code,
.runtime-skill-card code {
color: var(--muted);
font-size: 0.78rem;
overflow-wrap: anywhere;
}
.runtime-agent-card {
padding: 18px;
}
.runtime-card-head,
.runtime-skill-card header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-card-head h4 {
margin: 0;
}
.runtime-path-list,
.runtime-skill-list {
display: grid;
gap: 10px;
margin: 14px 0 0;
padding: 0;
list-style: none;
}
.runtime-path-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
border-radius: 14px;
background: rgba(15, 23, 42, 0.38);
padding: 12px;
}
.runtime-path-row > span {
display: grid;
gap: 4px;
min-width: 0;
}
.runtime-skill-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.runtime-skill-card {
padding: 14px;
}
.runtime-skill-card header > div {
display: grid;
gap: 4px;
min-width: 0;
}
.runtime-skill-list li {
display: grid;
gap: 3px;
border-top: 1px solid rgba(148, 163, 184, 0.12);
padding-top: 9px;
}
.runtime-skill-list li:first-child {
border-top: 0;
padding-top: 0;
}
.runtime-skill-list span {
color: var(--muted);
font-size: 0.82rem;
line-height: 1.4;
}
@media (max-width: 980px) {
.runtime-summary-card,
.runtime-skill-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.runtime-card-head,
.runtime-skill-card header,
.runtime-path-row {
flex-direction: column;
}
}

View File

@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import {
fetchRuntimeInventory,
type RuntimeAgentInventory,
type RuntimeInventorySnapshot,
type RuntimePathSnapshot,
type RuntimeSkillDirSnapshot,
} from './api';
import { SettingsSectionHeading } from './SettingsPanelChrome';
import './RuntimeInventorySettings.css';
function ExistsBadge({ exists }: { exists: boolean }) {
return (
<span className={`settings-account-badge ${exists ? 'is-active' : ''}`}>
{exists ? '감지됨' : '없음'}
</span>
);
}
function PathRow({ item }: { item: RuntimePathSnapshot }) {
return (
<li className="runtime-path-row">
<span>
<strong>{item.label}</strong>
<code>{item.path}</code>
</span>
<ExistsBadge exists={item.exists} />
</li>
);
}
function SkillDirCard({ dir }: { dir: RuntimeSkillDirSnapshot }) {
const preview = dir.skills.slice(0, 6);
return (
<article className="runtime-skill-card">
<header>
<div>
<strong>{dir.label}</strong>
<code>{dir.path}</code>
</div>
<span className="settings-account-badge is-active">
{dir.count} skills
</span>
</header>
{preview.length === 0 ? (
<p className="settings-hint"> SKILL.md </p>
) : (
<ul className="runtime-skill-list">
{preview.map((skill) => (
<li key={skill.path}>
<strong>{skill.name}</strong>
{skill.description ? <span>{skill.description}</span> : null}
</li>
))}
</ul>
)}
{dir.count > preview.length ? (
<small className="settings-hint">
{dir.count - preview.length}
</small>
) : null}
</article>
);
}
function AgentInventoryCard({
title,
inventory,
}: {
title: string;
inventory: RuntimeAgentInventory;
}) {
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4>{title}</h4>
<span className="settings-account-badge is-active">
MCP {inventory.mcp.ejclawConfigured ? '연결' : '미감지'}
</span>
</header>
<ul className="runtime-path-list">
{inventory.configFiles.map((item) => (
<PathRow item={item} key={item.path} />
))}
<PathRow item={inventory.mcp.configPath} />
</ul>
<p className="settings-hint">
MCP servers {inventory.mcp.serverCount} · EJClaw section{' '}
{inventory.mcp.ejclawConfigured ? '있음' : '없음'}
</p>
<div className="runtime-skill-grid">
{inventory.skillDirs.map((dir) => (
<SkillDirCard dir={dir} key={dir.path} />
))}
</div>
</article>
);
}
export function RuntimeInventorySettings() {
const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>(
null,
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchRuntimeInventory()
.then((value) => {
if (cancelled) return;
setSnapshot(value);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
return (
<section
aria-labelledby="settings-runtime-tab"
className="settings-section"
id="settings-runtime"
role="tabpanel"
>
<SettingsSectionHeading
detail="Runtime inventory"
title="런타임"
description="Codex/Claude Code 설정, 스킬, MCP 연결 상태를 읽기 전용으로 확인합니다."
/>
{error ? <p className="settings-error">{error}</p> : null}
{!snapshot ? (
<p className="settings-hint"> </p>
) : (
<div className="runtime-inventory">
<section className="runtime-summary-card">
<div>
<span className="settings-kicker">Current service</span>
<strong>{snapshot.service.id}</strong>
<small>
{snapshot.service.agentType} · session{' '}
{snapshot.service.sessionScope}
</small>
</div>
<div>
<span className="settings-kicker">Project</span>
<code>{snapshot.projectRoot}</code>
<small>data {snapshot.dataDir}</small>
</div>
</section>
<AgentInventoryCard title="Codex" inventory={snapshot.codex} />
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4>EJClaw bridge</h4>
<ExistsBadge exists={snapshot.ejclaw.mcpServer.exists} />
</header>
<ul className="runtime-path-list">
<PathRow item={snapshot.ejclaw.mcpServer} />
<PathRow item={snapshot.ejclaw.runnerSkillDir} />
</ul>
</article>
</div>
)}
</section>
);
}

View File

@@ -31,21 +31,24 @@ describe('SettingsPanel', () => {
expect(html).toContain('English');
});
it('renders model, MoA, fast mode, and account controls', () => {
it('renders model, runtime, MoA, fast mode, and account controls', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tabpanel"');
expect(html).toContain('aria-selected="true"');
expect(html).toContain('data-settings-target="settings-models"');
expect(html).toContain('data-settings-target="settings-runtime"');
expect(html).toContain('data-settings-target="settings-moa"');
expect(html).toContain('data-settings-target="settings-codex"');
expect(html).toContain('data-settings-target="settings-accounts"');
expect(html).toContain('aria-controls="settings-runtime"');
expect(html).toContain('aria-controls="settings-codex"');
expect(html).not.toContain('href="#settings-codex"');
expect(html).toContain('settings-apply-card');
expect(html).not.toContain('settings-apply-bar');
expect(html).toContain('저장 후 재시작');
expect(html).toContain('런타임');
expect(html).toContain('Claude');
expect(html).toContain('계정');
expect(html).toContain('스택 재시작');

View File

@@ -22,6 +22,7 @@ import {
} from './api';
import { type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
import {
GeneralSettings,
SettingsApplyCard,
@@ -83,6 +84,10 @@ export function SettingsPanel({
<ModelSettings />
</div>
<div hidden={activeSection !== 'settings-runtime'}>
<RuntimeInventorySettings />
</div>
<div hidden={activeSection !== 'settings-moa'}>
<MoaSettingsPanel />
</div>

View File

@@ -7,6 +7,11 @@ export const SETTINGS_NAV_ITEMS = [
title: '모델',
detail: 'owner · reviewer · arbiter',
},
{
targetId: 'settings-runtime',
title: '런타임',
detail: 'skills · MCP · config',
},
{ targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' },
{ targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' },
{ targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' },

View File

@@ -392,6 +392,52 @@ export interface CodexFeatureSnapshot {
goals: boolean;
}
export interface RuntimePathSnapshot {
label: string;
path: string;
exists: boolean;
}
export interface RuntimeSkillSummary {
name: string;
description: string | null;
path: string;
}
export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot {
count: number;
skills: RuntimeSkillSummary[];
}
export interface RuntimeMcpSnapshot {
configPath: RuntimePathSnapshot;
ejclawConfigured: boolean;
serverCount: number;
}
export interface RuntimeAgentInventory {
configFiles: RuntimePathSnapshot[];
skillDirs: RuntimeSkillDirSnapshot[];
mcp: RuntimeMcpSnapshot;
}
export interface RuntimeInventorySnapshot {
generatedAt: string;
projectRoot: string;
dataDir: string;
service: {
id: string;
sessionScope: string;
agentType: string;
};
codex: RuntimeAgentInventory;
claude: RuntimeAgentInventory;
ejclaw: {
runnerSkillDir: RuntimeSkillDirSnapshot;
mcpServer: RuntimePathSnapshot;
};
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
@@ -546,6 +592,10 @@ export async function fetchCodexFeatures(): Promise<CodexFeatureSnapshot> {
return fetchJson('/api/settings/codex-features');
}
export async function fetchRuntimeInventory(): Promise<RuntimeInventorySnapshot> {
return fetchJson('/api/settings/runtime-inventory');
}
export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> {

View File

@@ -47,6 +47,12 @@ async function main() {
await assertVisible(page.locator('.settings-panel'));
await assertVisible(page.locator('#settings-codex'));
await openSettingsSection(page, 'settings-runtime');
assert.equal(page.url(), originalUrl);
await assertVisible(page.locator('#settings-runtime'));
await page.getByText('Runtime inventory').waitFor();
await page.getByText('EJClaw bridge').waitFor();
await page
.locator(
'.settings-nav button[data-settings-target="settings-accounts"]',
@@ -308,6 +314,11 @@ async function handleMockApi(route: Route, state: MockApiState) {
return;
}
if (method === 'GET' && url.pathname === '/api/settings/runtime-inventory') {
await fulfillJson(route, runtimeInventoryFixture());
return;
}
if (url.pathname === '/api/settings/codex-features') {
if (method === 'GET') {
await fulfillJson(route, state.codexFeatures);
@@ -475,6 +486,103 @@ function mockStatusSnapshot() {
};
}
function runtimeSkill(name: string, description: string, skillPath: string) {
return { name, description, path: skillPath };
}
function runtimeInventoryFixture() {
const codexConfig = {
label: 'Codex config.toml',
path: '/home/.codex/config.toml',
exists: true,
};
const claudeSettings = {
label: 'Claude settings.json',
path: '/home/.claude/settings.json',
exists: true,
};
return {
generatedAt: '2026-05-04T00:00:00.000Z',
projectRoot: '/repo',
dataDir: '/repo/data',
service: {
id: 'codex-main',
sessionScope: 'codex-main',
agentType: 'codex',
},
codex: {
configFiles: [
codexConfig,
{
label: 'Codex auth.json',
path: '/home/.codex/auth.json',
exists: true,
},
],
skillDirs: [
{
label: 'Codex user skills',
path: '/home/.agents/skills',
exists: true,
count: 1,
skills: [
runtimeSkill(
'agent-browser',
'Browser automation',
'/home/.agents/skills/agent-browser',
),
],
},
],
mcp: { configPath: codexConfig, ejclawConfigured: true, serverCount: 1 },
},
claude: {
configFiles: [claudeSettings],
skillDirs: [
{
label: 'Claude user skills',
path: '/home/.claude/skills',
exists: true,
count: 1,
skills: [
runtimeSkill(
'review-helper',
'Review workflow',
'/home/.claude/skills/review-helper',
),
],
},
],
mcp: {
configPath: claudeSettings,
ejclawConfigured: false,
serverCount: 0,
},
},
ejclaw: {
runnerSkillDir: {
label: 'EJClaw runner skills',
path: '/repo/runners/skills',
exists: true,
count: 1,
skills: [
runtimeSkill(
'agent-browser',
'Browser automation',
'/repo/runners/skills/agent-browser',
),
],
},
mcpServer: {
label: 'EJClaw IPC MCP server',
path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js',
exists: true,
},
},
};
}
function parseJsonBody(body: string | null): Record<string, unknown> {
if (!body) return {};
const parsed = JSON.parse(body) as unknown;

View File

@@ -0,0 +1,91 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getRuntimeInventory } from './runtime-inventory.js';
describe('runtime inventory', () => {
let tempDir: string;
let homeDir: string;
let projectRoot: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-runtime-inv-'));
homeDir = path.join(tempDir, 'home');
projectRoot = path.join(tempDir, 'repo');
fs.mkdirSync(homeDir, { recursive: true });
fs.mkdirSync(projectRoot, { recursive: true });
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('summarizes safe Codex and Claude runtime metadata without reading secrets', () => {
const codexDir = path.join(homeDir, '.codex');
const claudeDir = path.join(homeDir, '.claude');
fs.mkdirSync(codexDir, { recursive: true });
fs.mkdirSync(claudeDir, { recursive: true });
fs.writeFileSync(
path.join(codexDir, 'config.toml'),
'[mcp_servers.ejclaw]\ncommand = "node"\n[mcp_servers.other]\ncommand = "x"\n',
);
fs.writeFileSync(
path.join(claudeDir, 'settings.json'),
'{"fastMode":true,"secret":"should-not-leak"}\n',
);
fs.writeFileSync(
path.join(codexDir, 'auth.json'),
'{"OPENAI_API_KEY":"sk-secret"}\n',
);
const codexSkill = path.join(homeDir, '.agents', 'skills', 'browser');
fs.mkdirSync(codexSkill, { recursive: true });
fs.writeFileSync(
path.join(codexSkill, 'SKILL.md'),
'---\nname: browser\ndescription: Browser automation\n---\n# Body\n',
);
const runnerSkill = path.join(projectRoot, 'runners', 'skills', 'runner');
fs.mkdirSync(runnerSkill, { recursive: true });
fs.writeFileSync(
path.join(runnerSkill, 'SKILL.md'),
'---\nname: runner\ndescription: Runner skill\n---\n',
);
const mcpServerPath = path.join(
projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
fs.mkdirSync(path.dirname(mcpServerPath), { recursive: true });
fs.writeFileSync(mcpServerPath, 'console.log("mcp");\n');
const snapshot = getRuntimeInventory({
generatedAt: '2026-05-04T00:00:00.000Z',
homeDir,
projectRoot,
});
expect(snapshot.projectRoot).toBe(projectRoot);
expect(snapshot.codex.mcp).toMatchObject({
ejclawConfigured: true,
serverCount: 2,
});
expect(snapshot.codex.skillDirs[0]).toMatchObject({
count: 1,
skills: [{ name: 'browser', description: 'Browser automation' }],
});
expect(snapshot.ejclaw.runnerSkillDir).toMatchObject({
count: 1,
skills: [{ name: 'runner', description: 'Runner skill' }],
});
const serialized = JSON.stringify(snapshot);
expect(serialized).not.toContain('sk-secret');
expect(serialized).not.toContain('should-not-leak');
});
});

212
src/runtime-inventory.ts Normal file
View File

@@ -0,0 +1,212 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
CURRENT_RUNTIME_AGENT_TYPE,
DATA_DIR,
SERVICE_ID,
SERVICE_SESSION_SCOPE,
} from './config.js';
export interface RuntimePathSnapshot {
label: string;
path: string;
exists: boolean;
}
export interface RuntimeSkillSummary {
name: string;
description: string | null;
path: string;
}
export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot {
count: number;
skills: RuntimeSkillSummary[];
}
export interface RuntimeMcpSnapshot {
configPath: RuntimePathSnapshot;
ejclawConfigured: boolean;
serverCount: number;
}
export interface RuntimeAgentInventory {
configFiles: RuntimePathSnapshot[];
skillDirs: RuntimeSkillDirSnapshot[];
mcp: RuntimeMcpSnapshot;
}
export interface RuntimeInventorySnapshot {
generatedAt: string;
projectRoot: string;
dataDir: string;
service: {
id: string;
sessionScope: string;
agentType: string;
};
codex: RuntimeAgentInventory;
claude: RuntimeAgentInventory;
ejclaw: {
runnerSkillDir: RuntimeSkillDirSnapshot;
mcpServer: RuntimePathSnapshot;
};
}
interface RuntimeInventoryOptions {
homeDir?: string;
projectRoot?: string;
generatedAt?: string;
}
function pathSnapshot(label: string, filePath: string): RuntimePathSnapshot {
return {
label,
path: filePath,
exists: fs.existsSync(filePath),
};
}
function parseSkillMetadata(skillMdPath: string): {
name: string | null;
description: string | null;
} {
let content: string;
try {
content = fs.readFileSync(skillMdPath, 'utf-8').slice(0, 8000);
} catch {
return { name: null, description: null };
}
const name = content.match(/^name:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null;
const description =
content.match(/^description:\s*(.+?)\s*$/m)?.[1]?.trim() ?? null;
return { name, description };
}
function readSkillDir(label: string, dirPath: string): RuntimeSkillDirSnapshot {
if (!fs.existsSync(dirPath)) {
return {
...pathSnapshot(label, dirPath),
count: 0,
skills: [],
};
}
const skills = fs
.readdirSync(dirPath, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => {
const skillDir = path.join(dirPath, entry.name);
const skillMdPath = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) return null;
const metadata = parseSkillMetadata(skillMdPath);
return {
name: metadata.name || entry.name,
description: metadata.description,
path: skillDir,
};
})
.filter((skill): skill is RuntimeSkillSummary => Boolean(skill))
.sort((a, b) => a.name.localeCompare(b.name));
return {
...pathSnapshot(label, dirPath),
count: skills.length,
skills,
};
}
function readMcpSnapshot(
label: string,
configPath: string,
): RuntimeMcpSnapshot {
let content = '';
if (fs.existsSync(configPath)) {
try {
content = fs.readFileSync(configPath, 'utf-8');
} catch {
content = '';
}
}
const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? [];
return {
configPath: pathSnapshot(label, configPath),
ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content),
serverCount: serverMatches.length,
};
}
export function getRuntimeInventory(
options: RuntimeInventoryOptions = {},
): RuntimeInventorySnapshot {
const homeDir = options.homeDir ?? os.homedir();
const projectRoot = options.projectRoot ?? process.cwd();
const codexConfigPath = path.join(homeDir, '.codex', 'config.toml');
const claudeSettingsPath = path.join(homeDir, '.claude', 'settings.json');
const runnerSkillDir = path.join(projectRoot, 'runners', 'skills');
const mcpServerPath = path.join(
projectRoot,
'runners',
'agent-runner',
'dist',
'ipc-mcp-stdio.js',
);
const ejclawRunnerSkills = readSkillDir(
'EJClaw runner skills',
runnerSkillDir,
);
return {
generatedAt: options.generatedAt ?? new Date().toISOString(),
projectRoot,
dataDir: DATA_DIR,
service: {
id: SERVICE_ID,
sessionScope: SERVICE_SESSION_SCOPE,
agentType: CURRENT_RUNTIME_AGENT_TYPE,
},
codex: {
configFiles: [
pathSnapshot('Codex config.toml', codexConfigPath),
pathSnapshot(
'Codex auth.json',
path.join(homeDir, '.codex', 'auth.json'),
),
],
skillDirs: [
readSkillDir(
'Codex user skills',
path.join(homeDir, '.agents', 'skills'),
),
ejclawRunnerSkills,
],
mcp: readMcpSnapshot('Codex config.toml', codexConfigPath),
},
claude: {
configFiles: [
pathSnapshot('Claude settings.json', claudeSettingsPath),
pathSnapshot(
'Claude credentials',
path.join(homeDir, '.claude', '.credentials.json'),
),
],
skillDirs: [
readSkillDir(
'Claude user skills',
path.join(homeDir, '.claude', 'skills'),
),
ejclawRunnerSkills,
],
mcp: readMcpSnapshot('Claude settings.json', claudeSettingsPath),
},
ejclaw: {
runnerSkillDir: ejclawRunnerSkills,
mcpServer: pathSnapshot('EJClaw IPC MCP server', mcpServerPath),
},
};
}

View File

@@ -11,6 +11,7 @@ import type {
FastModeSnapshot,
ModelConfigSnapshot,
} from './settings-store.js';
import type { RuntimeInventorySnapshot } from './runtime-inventory.js';
import type { MoaSettingsSnapshot } from './settings-store-moa.js';
function jsonResponse(value: unknown, init?: ResponseInit): Response {
@@ -44,6 +45,88 @@ const modelConfig: ModelConfigSnapshot = {
const fastMode: FastModeSnapshot = { codex: true, claude: false };
const codexFeatures: CodexFeatureSnapshot = { goals: false };
const runtimeInventory: RuntimeInventorySnapshot = {
generatedAt: '2026-05-04T00:00:00.000Z',
projectRoot: '/repo',
dataDir: '/repo/data',
service: {
id: 'codex-main',
sessionScope: 'codex-main',
agentType: 'codex',
},
codex: {
configFiles: [
{
label: 'Codex config.toml',
path: '/home/.codex/config.toml',
exists: true,
},
],
skillDirs: [
{
label: 'Codex user skills',
path: '/home/.agents/skills',
exists: true,
count: 1,
skills: [
{
name: 'agent-browser',
description: 'Browser automation',
path: '/home/.agents/skills/agent-browser',
},
],
},
],
mcp: {
configPath: {
label: 'Codex config.toml',
path: '/home/.codex/config.toml',
exists: true,
},
ejclawConfigured: true,
serverCount: 1,
},
},
claude: {
configFiles: [
{
label: 'Claude settings.json',
path: '/home/.claude/settings.json',
exists: true,
},
],
skillDirs: [],
mcp: {
configPath: {
label: 'Claude settings.json',
path: '/home/.claude/settings.json',
exists: true,
},
ejclawConfigured: false,
serverCount: 0,
},
},
ejclaw: {
runnerSkillDir: {
label: 'EJClaw runner skills',
path: '/repo/runners/skills',
exists: true,
count: 1,
skills: [
{
name: 'agent-browser',
description: 'Browser automation',
path: '/repo/runners/skills/agent-browser',
},
],
},
mcpServer: {
label: 'EJClaw IPC MCP server',
path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js',
exists: true,
},
},
};
const moaSettings: MoaSettingsSnapshot = {
enabled: true,
@@ -113,6 +196,7 @@ function makeDeps(
getFastMode: () => fastMode,
getModelConfig: () => modelConfig,
getMoaSettings: () => moaSettings,
getRuntimeInventory: () => runtimeInventory,
listClaudeAccounts: () => [makeClaudeAccount()],
listCodexAccounts: () => [makeCodexAccount()],
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
@@ -159,6 +243,14 @@ describe('web dashboard settings routes', () => {
expect(mode?.status).toBe(200);
await expect(mode?.json()).resolves.toEqual(fastMode);
const inventory = await route('/api/settings/runtime-inventory');
expect(inventory?.status).toBe(200);
await expect(inventory?.json()).resolves.toMatchObject({
service: { id: 'codex-main', agentType: 'codex' },
codex: { mcp: { ejclawConfigured: true } },
ejclaw: { runnerSkillDir: { count: 1 } },
});
const features = await route('/api/settings/codex-features');
expect(features?.status).toBe(200);
await expect(features?.json()).resolves.toEqual(codexFeatures);

View File

@@ -19,6 +19,10 @@ import {
type FastModeSnapshot,
type ModelConfigSnapshot,
} from './settings-store.js';
import {
getRuntimeInventory,
type RuntimeInventorySnapshot,
} from './runtime-inventory.js';
import {
checkMoaModel,
getMoaSettings,
@@ -40,6 +44,7 @@ export interface SettingsRouteDependencies {
getFastMode: typeof getFastMode;
getModelConfig: typeof getModelConfig;
getMoaSettings: typeof getMoaSettings;
getRuntimeInventory: typeof getRuntimeInventory;
listClaudeAccounts: typeof listClaudeAccounts;
listCodexAccounts: typeof listCodexAccounts;
refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
@@ -67,6 +72,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
getFastMode,
getModelConfig,
getMoaSettings,
getRuntimeInventory,
listClaudeAccounts,
listCodexAccounts,
refreshAllCodexAccounts,
@@ -207,6 +213,15 @@ function handleAccountsRoute(
});
}
function handleRuntimeInventoryRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Response | null {
if (!readMethod(request.method)) return null;
return jsonResponse(deps.getRuntimeInventory());
}
async function handleClaudeAccountAddRoute(
request: Request,
jsonResponse: JsonResponse,
@@ -320,6 +335,9 @@ export async function handleSettingsRoute({
if (url.pathname === '/api/settings/models') {
return handleModelSettingsRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/runtime-inventory') {
return handleRuntimeInventoryRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/fast-mode') {
return handleFastModeRoute(request, jsonResponse, deps);
}
@@ -366,4 +384,5 @@ export type {
FastModeSnapshot,
ModelConfigSnapshot,
MoaSettingsSnapshot,
RuntimeInventorySnapshot,
};