diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css
new file mode 100644
index 0000000..a35d15e
--- /dev/null
+++ b/apps/dashboard/src/RuntimeInventorySettings.css
@@ -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;
+ }
+}
diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx
new file mode 100644
index 0000000..3b4f51b
--- /dev/null
+++ b/apps/dashboard/src/RuntimeInventorySettings.tsx
@@ -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 (
+
+ {exists ? '감지됨' : '없음'}
+
+ );
+}
+
+function PathRow({ item }: { item: RuntimePathSnapshot }) {
+ return (
+
+
+ {item.label}
+ {item.path}
+
+
+
+ );
+}
+
+function SkillDirCard({ dir }: { dir: RuntimeSkillDirSnapshot }) {
+ const preview = dir.skills.slice(0, 6);
+ return (
+
+
+ {preview.length === 0 ? (
+ 표시할 SKILL.md 없음
+ ) : (
+
+ {preview.map((skill) => (
+ -
+ {skill.name}
+ {skill.description ? {skill.description} : null}
+
+ ))}
+
+ )}
+ {dir.count > preview.length ? (
+
+ 외 {dir.count - preview.length}개 더 있음
+
+ ) : null}
+
+ );
+}
+
+function AgentInventoryCard({
+ title,
+ inventory,
+}: {
+ title: string;
+ inventory: RuntimeAgentInventory;
+}) {
+ return (
+
+
+ {title}
+
+ MCP {inventory.mcp.ejclawConfigured ? '연결' : '미감지'}
+
+
+
+ {inventory.configFiles.map((item) => (
+
+ ))}
+
+
+
+ MCP servers {inventory.mcp.serverCount}개 · EJClaw section{' '}
+ {inventory.mcp.ejclawConfigured ? '있음' : '없음'}
+
+
+ {inventory.skillDirs.map((dir) => (
+
+ ))}
+
+
+ );
+}
+
+export function RuntimeInventorySettings() {
+ const [snapshot, setSnapshot] = useState(
+ null,
+ );
+ const [error, setError] = useState(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 (
+
+
+ {error ? {error}
: null}
+ {!snapshot ? (
+ 런타임 정보를 불러오는 중…
+ ) : (
+
+
+
+ Current service
+ {snapshot.service.id}
+
+ {snapshot.service.agentType} · session{' '}
+ {snapshot.service.sessionScope}
+
+
+
+ Project
+ {snapshot.projectRoot}
+ data {snapshot.dataDir}
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts
index b6d934e..de8af66 100644
--- a/apps/dashboard/src/SettingsPanel.test.ts
+++ b/apps/dashboard/src/SettingsPanel.test.ts
@@ -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('스택 재시작');
diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx
index 5727c3b..0f41c53 100644
--- a/apps/dashboard/src/SettingsPanel.tsx
+++ b/apps/dashboard/src/SettingsPanel.tsx
@@ -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({
+
+
+
+
diff --git a/apps/dashboard/src/SettingsPanelChrome.tsx b/apps/dashboard/src/SettingsPanelChrome.tsx
index d092c3d..1bca50a 100644
--- a/apps/dashboard/src/SettingsPanelChrome.tsx
+++ b/apps/dashboard/src/SettingsPanelChrome.tsx
@@ -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' },
diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts
index baf96e6..d7e5776 100644
--- a/apps/dashboard/src/api.ts
+++ b/apps/dashboard/src/api.ts
@@ -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 {
return fetchJson('/api/settings/codex-features');
}
+export async function fetchRuntimeInventory(): Promise {
+ return fetchJson('/api/settings/runtime-inventory');
+}
+
export async function updateCodexFeatures(
input: Partial,
): Promise {
diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts
index cb81f7f..e14ad6e 100644
--- a/scripts/dashboard-ux.ts
+++ b/scripts/dashboard-ux.ts
@@ -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 {
if (!body) return {};
const parsed = JSON.parse(body) as unknown;
diff --git a/src/runtime-inventory.test.ts b/src/runtime-inventory.test.ts
new file mode 100644
index 0000000..e214aa1
--- /dev/null
+++ b/src/runtime-inventory.test.ts
@@ -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');
+ });
+});
diff --git a/src/runtime-inventory.ts b/src/runtime-inventory.ts
new file mode 100644
index 0000000..f6d83c1
--- /dev/null
+++ b/src/runtime-inventory.ts
@@ -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),
+ },
+ };
+}
diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts
index 84ee2af..ebdaeaa 100644
--- a/src/web-dashboard-settings-routes.test.ts
+++ b/src/web-dashboard-settings-routes.test.ts
@@ -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);
diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts
index fcec7fe..a4ca32e 100644
--- a/src/web-dashboard-settings-routes.ts
+++ b/src/web-dashboard-settings-routes.ts
@@ -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,
};