[codex] Add room skill settings inventory (#132)

Add read-only room skill settings inventory.\n\nNo production deploy/restart.
This commit is contained in:
Eyejoker
2026-05-04 03:06:34 +09:00
committed by GitHub
parent 1c5be2094d
commit e90703d124
15 changed files with 902 additions and 6 deletions

View File

@@ -114,9 +114,67 @@
line-height: 1.4; line-height: 1.4;
} }
.runtime-room-skill-list {
display: grid;
gap: 12px;
margin-top: 14px;
}
.runtime-room-skill-card {
display: grid;
gap: 12px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.38);
padding: 14px;
}
.runtime-room-skill-card > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.runtime-room-skill-card > header > div {
display: grid;
gap: 3px;
min-width: 0;
}
.runtime-room-skill-card span,
.runtime-room-agent-policy small {
color: var(--muted);
}
.runtime-room-skill-card code {
color: var(--muted);
font-size: 0.72rem;
overflow-wrap: anywhere;
}
.runtime-room-agent-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.runtime-room-agent-policy {
display: grid;
gap: 4px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 14px;
padding: 12px;
}
.runtime-room-agent-policy > span {
color: var(--text);
font-size: 0.86rem;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.runtime-summary-card, .runtime-summary-card,
.runtime-skill-grid { .runtime-skill-grid,
.runtime-room-agent-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
@@ -124,7 +182,8 @@
@media (max-width: 720px) { @media (max-width: 720px) {
.runtime-card-head, .runtime-card-head,
.runtime-skill-card header, .runtime-skill-card header,
.runtime-path-row { .runtime-path-row,
.runtime-room-skill-card > header {
flex-direction: column; flex-direction: column;
} }
} }

View File

@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
fetchRoomSkillSettings,
fetchRuntimeInventory, fetchRuntimeInventory,
type RuntimeAgentInventory, type RuntimeAgentInventory,
type RuntimeInventorySnapshot, type RuntimeInventorySnapshot,
type RuntimePathSnapshot, type RuntimePathSnapshot,
type RuntimeSkillDirSnapshot, type RuntimeSkillDirSnapshot,
type RoomSkillCatalogItem,
type RoomSkillSettingsSnapshot,
} from './api'; } from './api';
import { SettingsSectionHeading } from './SettingsPanelChrome'; import { SettingsSectionHeading } from './SettingsPanelChrome';
import './RuntimeInventorySettings.css'; import './RuntimeInventorySettings.css';
@@ -98,18 +101,114 @@ function AgentInventoryCard({
); );
} }
function agentLabel(agentType: 'claude-code' | 'codex') {
return agentType === 'codex' ? 'Codex' : 'Claude Code';
}
function RoomSkillPolicyCard({
snapshot,
}: {
snapshot: RoomSkillSettingsSnapshot | null;
}) {
if (!snapshot) {
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<h4> </h4>
</header>
<p className="settings-hint"> </p>
</article>
);
}
const catalogById = new Map<string, RoomSkillCatalogItem>(
snapshot.catalog.map((skill) => [skill.id, skill]),
);
const roomPreview = snapshot.rooms.slice(0, 8);
return (
<article className="runtime-agent-card">
<header className="runtime-card-head">
<div>
<h4> </h4>
<p className="settings-hint">
. PR에서
enable/disable .
</p>
</div>
<span className="settings-account-badge is-active">
{snapshot.catalog.length} skills · {snapshot.rooms.length} rooms
</span>
</header>
{roomPreview.length === 0 ? (
<p className="settings-hint"> .</p>
) : (
<div className="runtime-room-skill-list">
{roomPreview.map((room) => (
<section className="runtime-room-skill-card" key={room.jid}>
<header>
<div>
<strong>{room.name}</strong>
<span>{room.folder}</span>
</div>
<code>{room.jid}</code>
</header>
<div className="runtime-room-agent-grid">
{room.agents.map((agent) => {
const disabledNames = agent.disabledSkillIds
.map((id) => catalogById.get(id)?.displayName ?? id)
.slice(0, 3);
return (
<div
className="runtime-room-agent-policy"
key={`${room.jid}:${agent.agentType}`}
>
<strong>{agentLabel(agent.agentType)}</strong>
<span>
{agent.mode === 'all-enabled'
? '기본 전체 ON'
: `${agent.disabledSkillIds.length}개 OFF`}
</span>
<small>
{agent.effectiveEnabledSkillIds.length} / {' '}
{agent.availableSkillIds.length}
</small>
{disabledNames.length > 0 ? (
<small>OFF: {disabledNames.join(', ')}</small>
) : null}
</div>
);
})}
</div>
</section>
))}
</div>
)}
{snapshot.rooms.length > roomPreview.length ? (
<small className="settings-hint">
{snapshot.rooms.length - roomPreview.length}
</small>
) : null}
</article>
);
}
export function RuntimeInventorySettings() { export function RuntimeInventorySettings() {
const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>( const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>(
null, null,
); );
const [roomSkillSnapshot, setRoomSkillSnapshot] =
useState<RoomSkillSettingsSnapshot | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
fetchRuntimeInventory() Promise.all([fetchRuntimeInventory(), fetchRoomSkillSettings()])
.then((value) => { .then(([value, roomSkills]) => {
if (cancelled) return; if (cancelled) return;
setSnapshot(value); setSnapshot(value);
setRoomSkillSnapshot(roomSkills);
setError(null); setError(null);
}) })
.catch((err) => { .catch((err) => {
@@ -156,6 +255,7 @@ export function RuntimeInventorySettings() {
<AgentInventoryCard title="Codex" inventory={snapshot.codex} /> <AgentInventoryCard title="Codex" inventory={snapshot.codex} />
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} /> <AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
<RoomSkillPolicyCard snapshot={roomSkillSnapshot} />
<article className="runtime-agent-card"> <article className="runtime-agent-card">
<header className="runtime-card-head"> <header className="runtime-card-head">

View File

@@ -438,6 +438,41 @@ export interface RuntimeInventorySnapshot {
}; };
} }
export type RoomSkillScope = 'codex-user' | 'claude-user' | 'runner';
export interface RoomSkillCatalogItem {
id: string;
scope: RoomSkillScope;
name: string;
displayName: string;
description: string | null;
path: string;
agentTypes: Array<'claude-code' | 'codex'>;
}
export interface RoomSkillAgentPolicy {
agentType: 'claude-code' | 'codex';
mode: 'all-enabled' | 'custom';
availableSkillIds: string[];
disabledSkillIds: string[];
explicitEnabledSkillIds: string[];
effectiveEnabledSkillIds: string[];
}
export interface RoomSkillPolicyRoom {
jid: string;
name: string;
folder: string;
roomMode?: 'single' | 'tribunal';
agents: RoomSkillAgentPolicy[];
}
export interface RoomSkillSettingsSnapshot {
generatedAt: string;
catalog: RoomSkillCatalogItem[];
rooms: RoomSkillPolicyRoom[];
}
export interface MoaReferenceStatus { export interface MoaReferenceStatus {
model: string; model: string;
checkedAt: string; checkedAt: string;
@@ -596,6 +631,10 @@ export async function fetchRuntimeInventory(): Promise<RuntimeInventorySnapshot>
return fetchJson('/api/settings/runtime-inventory'); return fetchJson('/api/settings/runtime-inventory');
} }
export async function fetchRoomSkillSettings(): Promise<RoomSkillSettingsSnapshot> {
return fetchJson('/api/settings/room-skills');
}
export async function updateCodexFeatures( export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>, input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> { ): Promise<CodexFeatureSnapshot> {

View File

@@ -319,6 +319,11 @@ async function handleMockApi(route: Route, state: MockApiState) {
return; return;
} }
if (method === 'GET' && url.pathname === '/api/settings/room-skills') {
await fulfillJson(route, roomSkillSettingsFixture());
return;
}
if (url.pathname === '/api/settings/codex-features') { if (url.pathname === '/api/settings/codex-features') {
if (method === 'GET') { if (method === 'GET') {
await fulfillJson(route, state.codexFeatures); await fulfillJson(route, state.codexFeatures);
@@ -583,6 +588,76 @@ function runtimeInventoryFixture() {
}; };
} }
function roomSkillSettingsFixture() {
return {
generatedAt: '2026-05-04T00:00:00.000Z',
catalog: [
{
id: 'codex-user:agent-browser',
scope: 'codex-user',
name: 'agent-browser',
displayName: 'agent-browser',
description: 'Browser automation',
path: '/home/.agents/skills/agent-browser',
agentTypes: ['codex'],
},
{
id: 'claude-user:review-helper',
scope: 'claude-user',
name: 'review-helper',
displayName: 'review-helper',
description: 'Review workflow',
path: '/home/.claude/skills/review-helper',
agentTypes: ['claude-code'],
},
{
id: 'runner:agent-browser',
scope: 'runner',
name: 'agent-browser',
displayName: 'agent-browser',
description: 'Browser automation',
path: '/repo/runners/skills/agent-browser',
agentTypes: ['claude-code', 'codex'],
},
],
rooms: [
{
jid: 'room@example',
name: 'EJClaw',
folder: 'ejclaw',
roomMode: 'tribunal',
agents: [
{
agentType: 'codex',
mode: 'all-enabled',
availableSkillIds: [
'codex-user:agent-browser',
'runner:agent-browser',
],
disabledSkillIds: [],
explicitEnabledSkillIds: [],
effectiveEnabledSkillIds: [
'codex-user:agent-browser',
'runner:agent-browser',
],
},
{
agentType: 'claude-code',
mode: 'custom',
availableSkillIds: [
'claude-user:review-helper',
'runner:agent-browser',
],
disabledSkillIds: ['claude-user:review-helper'],
explicitEnabledSkillIds: [],
effectiveEnabledSkillIds: ['runner:agent-browser'],
},
],
},
],
};
}
function parseJsonBody(body: string | null): Record<string, unknown> { function parseJsonBody(body: string | null): Record<string, unknown> {
if (!body) return {}; if (!body) return {};
const parsed = JSON.parse(body) as unknown; const parsed = JSON.parse(body) as unknown;

View File

@@ -62,6 +62,7 @@ export {
getSessionForAgentType, getSessionForAgentType,
getStoredRoomRoleAgentPlan, getStoredRoomRoleAgentPlan,
getStoredRoomSettings, getStoredRoomSettings,
getStoredRoomSkillOverrides,
getTaskById, getTaskById,
getTasksForGroup, getTasksForGroup,
hasActiveCiWatcherForChat, hasActiveCiWatcherForChat,

View File

@@ -1,5 +1,25 @@
import { Database } from 'bun:sqlite'; import { Database } from 'bun:sqlite';
const ROOM_SKILL_OVERRIDES_SCHEMA = `
CREATE TABLE IF NOT EXISTS room_skill_overrides (
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_scope TEXT NOT NULL,
skill_name TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
CHECK (agent_type IN ('claude-code', 'codex')),
CHECK (enabled IN (0, 1)),
CHECK (length(skill_scope) > 0),
CHECK (length(skill_name) > 0)
);
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
ON room_skill_overrides(chat_jid, agent_type);
`;
export function applyBaseSchema(database: Database): void { export function applyBaseSchema(database: Database): void {
database.exec(` database.exec(`
CREATE TABLE IF NOT EXISTS chats ( CREATE TABLE IF NOT EXISTS chats (
@@ -410,8 +430,7 @@ export function applyBaseSchema(database: Database): void {
last_used_at TEXT, last_used_at TEXT,
archived_at TEXT archived_at TEXT
); );
CREATE INDEX IF NOT EXISTS idx_memories_scope CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope_kind, scope_key);
ON memories(scope_kind, scope_key);
CREATE INDEX IF NOT EXISTS idx_memories_active CREATE INDEX IF NOT EXISTS idx_memories_active
ON memories(scope_kind, scope_key, archived_at, created_at); ON memories(scope_kind, scope_key, archived_at, created_at);
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
@@ -435,4 +454,5 @@ export function applyBaseSchema(database: Database): void {
VALUES (new.id, new.content, new.keywords_json); VALUES (new.id, new.content, new.keywords_json);
END; END;
`); `);
database.exec(ROOM_SKILL_OVERRIDES_SCHEMA);
} }

View File

@@ -40,6 +40,7 @@ function getExpectedSchemaMigrations(): Array<{
{ version: 13, name: 'message_source_kind' }, { version: 13, name: 'message_source_kind' },
{ version: 14, name: 'work_item_attachments' }, { version: 14, name: 'work_item_attachments' },
{ version: 15, name: 'turn_progress_text' }, { version: 15, name: 'turn_progress_text' },
{ version: 16, name: 'room_skill_overrides' },
]; ];
} }

View File

@@ -0,0 +1,29 @@
import type { Database } from 'bun:sqlite';
import type { SchemaMigrationDefinition } from './types.js';
export const ROOM_SKILL_OVERRIDES_MIGRATION: SchemaMigrationDefinition = {
version: 16,
name: 'room_skill_overrides',
apply(database: Database) {
database.exec(`
CREATE TABLE IF NOT EXISTS room_skill_overrides (
chat_jid TEXT NOT NULL,
agent_type TEXT NOT NULL,
skill_scope TEXT NOT NULL,
skill_name TEXT NOT NULL,
enabled INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (chat_jid, agent_type, skill_scope, skill_name),
FOREIGN KEY (chat_jid) REFERENCES room_settings(chat_jid) ON DELETE CASCADE,
CHECK (agent_type IN ('claude-code', 'codex')),
CHECK (enabled IN (0, 1)),
CHECK (length(skill_scope) > 0),
CHECK (length(skill_name) > 0)
);
CREATE INDEX IF NOT EXISTS idx_room_skill_overrides_room
ON room_skill_overrides(chat_jid, agent_type);
`);
},
};

View File

@@ -15,6 +15,7 @@ import { PAIRED_VERDICT_AND_STEP_TELEMETRY_MIGRATION } from './012_paired-verdic
import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js'; import { MESSAGE_SOURCE_KIND_MIGRATION } from './013_message-source-kind.js';
import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js'; import { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.js';
import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js'; import { TURN_PROGRESS_TEXT_MIGRATION } from './015_turn-progress-text.js';
import { ROOM_SKILL_OVERRIDES_MIGRATION } from './016_room-skill-overrides.js';
import type { import type {
SchemaMigrationArgs, SchemaMigrationArgs,
SchemaMigrationDefinition, SchemaMigrationDefinition,
@@ -38,6 +39,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [
MESSAGE_SOURCE_KIND_MIGRATION, MESSAGE_SOURCE_KIND_MIGRATION,
WORK_ITEM_ATTACHMENTS_MIGRATION, WORK_ITEM_ATTACHMENTS_MIGRATION,
TURN_PROGRESS_TEXT_MIGRATION, TURN_PROGRESS_TEXT_MIGRATION,
ROOM_SKILL_OVERRIDES_MIGRATION,
]; ];
function ensureSchemaMigrationsTable(database: Database): void { function ensureSchemaMigrationsTable(database: Database): void {

View File

@@ -28,6 +28,16 @@ interface StoredRoomModeRow {
source: RoomModeSource; source: RoomModeSource;
} }
export interface StoredRoomSkillOverride {
chatJid: string;
agentType: AgentType;
skillScope: string;
skillName: string;
enabled: boolean;
createdAt: string;
updatedAt: string;
}
export interface AssignRoomInput { export interface AssignRoomInput {
name: string; name: string;
roomMode?: RoomMode; roomMode?: RoomMode;
@@ -307,6 +317,62 @@ export function getStoredRoomRoleAgentPlanFromDatabase(
return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined; return stored ? resolveStoredRoomRoleAgentPlan(database, stored) : undefined;
} }
export function getStoredRoomSkillOverridesFromDatabase(
database: Database,
chatJid?: string,
): StoredRoomSkillOverride[] {
const params: string[] = [];
const where = chatJid ? 'WHERE chat_jid = ?' : '';
if (chatJid) params.push(chatJid);
let rows: Array<{
chat_jid: string;
agent_type: string;
skill_scope: string;
skill_name: string;
enabled: number;
created_at: string;
updated_at: string;
}>;
try {
rows = database
.prepare(
`SELECT chat_jid, agent_type, skill_scope, skill_name, enabled,
created_at, updated_at
FROM room_skill_overrides
${where}
ORDER BY chat_jid, agent_type, skill_scope, skill_name`,
)
.all(...params) as Array<{
chat_jid: string;
agent_type: string;
skill_scope: string;
skill_name: string;
enabled: number;
created_at: string;
updated_at: string;
}>;
} catch {
return [];
}
return rows
.map((row) => {
const agentType = normalizeStoredAgentType(row.agent_type);
if (!agentType) return null;
return {
chatJid: row.chat_jid,
agentType,
skillScope: row.skill_scope,
skillName: row.skill_name,
enabled: row.enabled === 1,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
})
.filter((row): row is StoredRoomSkillOverride => Boolean(row));
}
function getStoredRoomModeRowFromDatabase( function getStoredRoomModeRowFromDatabase(
database: Database, database: Database,
chatJid: string, chatJid: string,

View File

@@ -36,9 +36,11 @@ import {
getRegisteredGroupFromDatabase, getRegisteredGroupFromDatabase,
getStoredRoomRoleAgentPlanFromDatabase, getStoredRoomRoleAgentPlanFromDatabase,
getStoredRoomSettingsFromDatabase, getStoredRoomSettingsFromDatabase,
getStoredRoomSkillOverridesFromDatabase,
setExplicitRoomModeInDatabase, setExplicitRoomModeInDatabase,
setRegisteredGroupForTestsInDatabase, setRegisteredGroupForTestsInDatabase,
setStoredRoomOwnerAgentTypeForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase,
type StoredRoomSkillOverride,
updateRegisteredGroupNameInDatabase, updateRegisteredGroupNameInDatabase,
} from './rooms.js'; } from './rooms.js';
import { type StoredRoomSettings } from './room-registration.js'; import { type StoredRoomSettings } from './room-registration.js';
@@ -363,6 +365,14 @@ export function getStoredRoomRoleAgentPlan(
return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid); return getStoredRoomRoleAgentPlanFromDatabase(db, chatJid);
} }
export function getStoredRoomSkillOverrides(
chatJid?: string,
): StoredRoomSkillOverride[] {
const db = getDatabaseIfInitialized();
if (!db) return [];
return getStoredRoomSkillOverridesFromDatabase(db, chatJid);
}
export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { export function getExplicitRoomMode(chatJid: string): RoomMode | undefined {
return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid);
} }

View File

@@ -0,0 +1,195 @@
import { Database } from 'bun:sqlite';
import { describe, expect, it } from 'vitest';
import { initializeDatabaseSchema } from './db/bootstrap.js';
import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js';
import {
buildRoomSkillSettingsSnapshot,
type RoomSkillSettingsBuildInput,
} from './room-skill-settings.js';
import type { RuntimeInventorySnapshot } from './runtime-inventory.js';
function skill(name: string, skillPath: string, description = `${name} desc`) {
return { name, description, path: skillPath };
}
function inventoryFixture(): RuntimeInventorySnapshot {
return {
generatedAt: '2026-05-04T00:00:00.000Z',
projectRoot: '/repo',
dataDir: '/repo/data',
service: {
id: 'codex-main',
sessionScope: 'codex-main',
agentType: 'codex',
},
codex: {
configFiles: [],
skillDirs: [
{
label: 'Codex user skills',
path: '/home/.agents/skills',
exists: true,
count: 1,
skills: [skill('agent-browser', '/home/.agents/skills/browser')],
},
],
mcp: {
configPath: {
label: 'Codex config.toml',
path: '/home/.codex/config.toml',
exists: true,
},
ejclawConfigured: true,
serverCount: 1,
},
},
claude: {
configFiles: [],
skillDirs: [
{
label: 'Claude user skills',
path: '/home/.claude/skills',
exists: true,
count: 1,
skills: [
skill('review-helper', '/home/.claude/skills/review-helper'),
],
},
],
mcp: {
configPath: {
label: 'Claude settings.json',
path: '/home/.claude/settings.json',
exists: true,
},
ejclawConfigured: true,
serverCount: 1,
},
},
ejclaw: {
runnerSkillDir: {
label: 'EJClaw runner skills',
path: '/repo/runners/skills',
exists: true,
count: 1,
skills: [
skill('runner-browser', '/repo/runners/skills/runner-browser'),
],
},
mcpServer: {
label: 'EJClaw IPC MCP server',
path: '/repo/runners/agent-runner/dist/ipc-mcp-stdio.js',
exists: true,
},
},
};
}
describe('room skill settings', () => {
it('builds room-scoped effective skill policy from inventory and overrides', () => {
const input: RoomSkillSettingsBuildInput = {
generatedAt: '2026-05-04T00:00:00.000Z',
inventory: inventoryFixture(),
roomBindings: {
'room-1': {
name: 'Room One',
folder: 'room-one',
added_at: '2026-05-04T00:00:00.000Z',
agentType: 'codex',
},
},
registeredAgentTypesByJid: new Map([
['room-1', ['claude-code', 'codex']],
]),
overrides: [
{
chatJid: 'room-1',
agentType: 'claude-code',
skillScope: 'claude-user',
skillName: 'review-helper',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
],
};
const snapshot = buildRoomSkillSettingsSnapshot(input);
expect(snapshot.catalog.map((skill) => skill.id).sort()).toEqual([
'claude-user:review-helper',
'codex-user:browser',
'runner:runner-browser',
]);
expect(snapshot.rooms).toHaveLength(1);
const room = snapshot.rooms[0]!;
expect(room.agents.map((agent) => agent.agentType)).toEqual([
'claude-code',
'codex',
]);
expect(
room.agents.find((agent) => agent.agentType === 'codex'),
).toMatchObject({
mode: 'all-enabled',
availableSkillIds: ['codex-user:browser', 'runner:runner-browser'],
effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'],
});
expect(
room.agents.find((agent) => agent.agentType === 'claude-code'),
).toMatchObject({
mode: 'custom',
availableSkillIds: ['claude-user:review-helper', 'runner:runner-browser'],
disabledSkillIds: ['claude-user:review-helper'],
effectiveEnabledSkillIds: ['runner:runner-browser'],
});
});
it('reads normalized room skill overrides from the database', () => {
const database = new Database(':memory:');
try {
initializeDatabaseSchema(database);
database
.prepare(
`INSERT INTO room_settings (
chat_jid, room_mode, mode_source, name, folder, updated_at
) VALUES (?, 'single', 'explicit', ?, ?, ?)`,
)
.run('room-1', 'Room One', 'room-one', '2026-05-04T00:00:00.000Z');
database
.prepare(
`INSERT INTO room_skill_overrides (
chat_jid, agent_type, skill_scope, skill_name, enabled,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
'room-1',
'codex',
'runner',
'agent-browser',
0,
'2026-05-04T00:00:00.000Z',
'2026-05-04T00:00:00.000Z',
);
expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([
{
chatJid: 'room-1',
agentType: 'codex',
skillScope: 'runner',
skillName: 'agent-browser',
enabled: false,
createdAt: '2026-05-04T00:00:00.000Z',
updatedAt: '2026-05-04T00:00:00.000Z',
},
]);
expect(
getStoredRoomSkillOverridesFromDatabase(database, 'missing-room'),
).toEqual([]);
} finally {
database.close();
}
});
});

239
src/room-skill-settings.ts Normal file
View File

@@ -0,0 +1,239 @@
import path from 'node:path';
import {
getAllRoomBindings,
getRegisteredAgentTypesForJid,
getStoredRoomSkillOverrides,
} from './db.js';
import {
getRuntimeInventory,
type RuntimeInventorySnapshot,
type RuntimeSkillDirSnapshot,
type RuntimeSkillSummary,
} from './runtime-inventory.js';
import type { AgentType, RegisteredGroup, RoomMode } from './types.js';
import type { StoredRoomSkillOverride } from './db/rooms.js';
export type RoomSkillScope = 'codex-user' | 'claude-user' | 'runner';
export interface RoomSkillCatalogItem {
id: string;
scope: RoomSkillScope;
name: string;
displayName: string;
description: string | null;
path: string;
agentTypes: AgentType[];
}
export interface RoomSkillAgentPolicy {
agentType: AgentType;
mode: 'all-enabled' | 'custom';
availableSkillIds: string[];
disabledSkillIds: string[];
explicitEnabledSkillIds: string[];
effectiveEnabledSkillIds: string[];
}
export interface RoomSkillPolicyRoom {
jid: string;
name: string;
folder: string;
roomMode?: RoomMode;
agents: RoomSkillAgentPolicy[];
}
export interface RoomSkillSettingsSnapshot {
generatedAt: string;
catalog: RoomSkillCatalogItem[];
rooms: RoomSkillPolicyRoom[];
}
export interface RoomSkillSettingsBuildInput {
generatedAt?: string;
inventory: RuntimeInventorySnapshot;
roomBindings: Record<string, RegisteredGroup>;
registeredAgentTypesByJid?: Map<string, AgentType[]>;
overrides?: StoredRoomSkillOverride[];
}
function skillId(scope: RoomSkillScope, name: string): string {
return `${scope}:${name}`;
}
function skillNameFromPath(skill: RuntimeSkillSummary): string {
return path.basename(skill.path);
}
function addSkillsFromDir(
catalog: Map<string, RoomSkillCatalogItem>,
dir: RuntimeSkillDirSnapshot,
scope: RoomSkillScope,
agentTypes: AgentType[],
): void {
if (!dir.exists) return;
for (const skill of dir.skills) {
const name = skillNameFromPath(skill);
if (!name) continue;
const id = skillId(scope, name);
const existing = catalog.get(id);
if (existing) {
const types = new Set<AgentType>([...existing.agentTypes, ...agentTypes]);
existing.agentTypes = [...types].sort();
continue;
}
catalog.set(id, {
id,
scope,
name,
displayName: skill.name,
description: skill.description,
path: skill.path,
agentTypes: [...agentTypes].sort(),
});
}
}
export function buildRoomSkillCatalog(
inventory: RuntimeInventorySnapshot,
): RoomSkillCatalogItem[] {
const catalog = new Map<string, RoomSkillCatalogItem>();
const runnerPath = inventory.ejclaw.runnerSkillDir.path;
const codexUserDir = inventory.codex.skillDirs.find(
(dir) => dir.path !== runnerPath,
);
const claudeUserDir = inventory.claude.skillDirs.find(
(dir) => dir.path !== runnerPath,
);
if (codexUserDir) {
addSkillsFromDir(catalog, codexUserDir, 'codex-user', ['codex']);
}
if (claudeUserDir) {
addSkillsFromDir(catalog, claudeUserDir, 'claude-user', ['claude-code']);
}
addSkillsFromDir(catalog, inventory.ejclaw.runnerSkillDir, 'runner', [
'claude-code',
'codex',
]);
return [...catalog.values()].sort((a, b) => {
const scopeCompare = a.scope.localeCompare(b.scope);
return scopeCompare === 0
? a.displayName.localeCompare(b.displayName)
: scopeCompare;
});
}
function overrideKey(
jid: string,
agentType: AgentType,
scope: string,
name: string,
): string {
return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`;
}
function resolveRoomAgentTypes(
jid: string,
group: RegisteredGroup,
registeredAgentTypesByJid?: Map<string, AgentType[]>,
): AgentType[] {
const registered = registeredAgentTypesByJid?.get(jid) ?? [];
const types = new Set<AgentType>(registered);
if (group.agentType) types.add(group.agentType);
if (types.size === 0) types.add('claude-code');
return [...types].sort();
}
export function buildRoomSkillSettingsSnapshot({
generatedAt,
inventory,
roomBindings,
registeredAgentTypesByJid,
overrides = [],
}: RoomSkillSettingsBuildInput): RoomSkillSettingsSnapshot {
const catalog = buildRoomSkillCatalog(inventory);
const overridesByKey = new Map(
overrides.map((override) => [
overrideKey(
override.chatJid,
override.agentType,
override.skillScope,
override.skillName,
),
override,
]),
);
const rooms = Object.entries(roomBindings)
.map(([jid, group]) => {
const agentTypes = resolveRoomAgentTypes(
jid,
group,
registeredAgentTypesByJid,
);
return {
jid,
name: group.name,
folder: group.folder,
agents: agentTypes.map((agentType) => {
const available = catalog.filter((skill) =>
skill.agentTypes.includes(agentType),
);
const disabledSkillIds: string[] = [];
const explicitEnabledSkillIds: string[] = [];
const effectiveEnabledSkillIds: string[] = [];
for (const skill of available) {
const override = overridesByKey.get(
overrideKey(jid, agentType, skill.scope, skill.name),
);
if (override?.enabled === false) {
disabledSkillIds.push(skill.id);
continue;
}
if (override?.enabled === true) {
explicitEnabledSkillIds.push(skill.id);
}
effectiveEnabledSkillIds.push(skill.id);
}
return {
agentType,
mode:
disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0
? 'custom'
: 'all-enabled',
availableSkillIds: available.map((skill) => skill.id),
disabledSkillIds,
explicitEnabledSkillIds,
effectiveEnabledSkillIds,
} satisfies RoomSkillAgentPolicy;
}),
} satisfies RoomSkillPolicyRoom;
})
.sort((a, b) => a.name.localeCompare(b.name));
return {
generatedAt: generatedAt ?? new Date().toISOString(),
catalog,
rooms,
};
}
export function getRoomSkillSettings(): RoomSkillSettingsSnapshot {
const roomBindings = getAllRoomBindings();
const registeredAgentTypesByJid = new Map(
Object.keys(roomBindings).map((jid) => [
jid,
getRegisteredAgentTypesForJid(jid),
]),
);
return buildRoomSkillSettingsSnapshot({
inventory: getRuntimeInventory(),
roomBindings,
registeredAgentTypesByJid,
overrides: getStoredRoomSkillOverrides(),
});
}

View File

@@ -12,6 +12,7 @@ import type {
ModelConfigSnapshot, ModelConfigSnapshot,
} from './settings-store.js'; } from './settings-store.js';
import type { RuntimeInventorySnapshot } from './runtime-inventory.js'; import type { RuntimeInventorySnapshot } from './runtime-inventory.js';
import type { RoomSkillSettingsSnapshot } from './room-skill-settings.js';
import type { MoaSettingsSnapshot } from './settings-store-moa.js'; import type { MoaSettingsSnapshot } from './settings-store-moa.js';
function jsonResponse(value: unknown, init?: ResponseInit): Response { function jsonResponse(value: unknown, init?: ResponseInit): Response {
@@ -158,6 +159,38 @@ const moaSettings: MoaSettingsSnapshot = {
], ],
}; };
const roomSkillSettings: RoomSkillSettingsSnapshot = {
generatedAt: '2026-05-04T00:00:00.000Z',
catalog: [
{
id: 'runner:agent-browser',
scope: 'runner',
name: 'agent-browser',
displayName: 'agent-browser',
description: 'Browser automation',
path: '/repo/runners/skills/agent-browser',
agentTypes: ['claude-code', 'codex'],
},
],
rooms: [
{
jid: 'room@example',
name: 'EJClaw',
folder: 'ejclaw',
agents: [
{
agentType: 'codex',
mode: 'all-enabled',
availableSkillIds: ['runner:agent-browser'],
disabledSkillIds: [],
explicitEnabledSkillIds: [],
effectiveEnabledSkillIds: ['runner:agent-browser'],
},
],
},
],
};
function makeClaudeAccount(): ClaudeAccountSummary { function makeClaudeAccount(): ClaudeAccountSummary {
return { return {
index: 0, index: 0,
@@ -197,6 +230,7 @@ function makeDeps(
getModelConfig: () => modelConfig, getModelConfig: () => modelConfig,
getMoaSettings: () => moaSettings, getMoaSettings: () => moaSettings,
getRuntimeInventory: () => runtimeInventory, getRuntimeInventory: () => runtimeInventory,
getRoomSkillSettings: () => roomSkillSettings,
listClaudeAccounts: () => [makeClaudeAccount()], listClaudeAccounts: () => [makeClaudeAccount()],
listCodexAccounts: () => [makeCodexAccount()], listCodexAccounts: () => [makeCodexAccount()],
refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }),
@@ -251,6 +285,13 @@ describe('web dashboard settings routes', () => {
ejclaw: { runnerSkillDir: { count: 1 } }, ejclaw: { runnerSkillDir: { count: 1 } },
}); });
const roomSkills = await route('/api/settings/room-skills');
expect(roomSkills?.status).toBe(200);
await expect(roomSkills?.json()).resolves.toMatchObject({
catalog: [{ id: 'runner:agent-browser' }],
rooms: [{ jid: 'room@example', agents: [{ agentType: 'codex' }] }],
});
const features = await route('/api/settings/codex-features'); const features = await route('/api/settings/codex-features');
expect(features?.status).toBe(200); expect(features?.status).toBe(200);
await expect(features?.json()).resolves.toEqual(codexFeatures); await expect(features?.json()).resolves.toEqual(codexFeatures);

View File

@@ -23,6 +23,10 @@ import {
getRuntimeInventory, getRuntimeInventory,
type RuntimeInventorySnapshot, type RuntimeInventorySnapshot,
} from './runtime-inventory.js'; } from './runtime-inventory.js';
import {
getRoomSkillSettings,
type RoomSkillSettingsSnapshot,
} from './room-skill-settings.js';
import { import {
checkMoaModel, checkMoaModel,
getMoaSettings, getMoaSettings,
@@ -45,6 +49,7 @@ export interface SettingsRouteDependencies {
getModelConfig: typeof getModelConfig; getModelConfig: typeof getModelConfig;
getMoaSettings: typeof getMoaSettings; getMoaSettings: typeof getMoaSettings;
getRuntimeInventory: typeof getRuntimeInventory; getRuntimeInventory: typeof getRuntimeInventory;
getRoomSkillSettings: typeof getRoomSkillSettings;
listClaudeAccounts: typeof listClaudeAccounts; listClaudeAccounts: typeof listClaudeAccounts;
listCodexAccounts: typeof listCodexAccounts; listCodexAccounts: typeof listCodexAccounts;
refreshAllCodexAccounts: typeof refreshAllCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts;
@@ -73,6 +78,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = {
getModelConfig, getModelConfig,
getMoaSettings, getMoaSettings,
getRuntimeInventory, getRuntimeInventory,
getRoomSkillSettings,
listClaudeAccounts, listClaudeAccounts,
listCodexAccounts, listCodexAccounts,
refreshAllCodexAccounts, refreshAllCodexAccounts,
@@ -222,6 +228,15 @@ function handleRuntimeInventoryRoute(
return jsonResponse(deps.getRuntimeInventory()); return jsonResponse(deps.getRuntimeInventory());
} }
function handleRoomSkillsRoute(
request: Request,
jsonResponse: JsonResponse,
deps: SettingsRouteDependencies,
): Response | null {
if (!readMethod(request.method)) return null;
return jsonResponse(deps.getRoomSkillSettings());
}
async function handleClaudeAccountAddRoute( async function handleClaudeAccountAddRoute(
request: Request, request: Request,
jsonResponse: JsonResponse, jsonResponse: JsonResponse,
@@ -338,6 +353,9 @@ export async function handleSettingsRoute({
if (url.pathname === '/api/settings/runtime-inventory') { if (url.pathname === '/api/settings/runtime-inventory') {
return handleRuntimeInventoryRoute(request, jsonResponse, deps); return handleRuntimeInventoryRoute(request, jsonResponse, deps);
} }
if (url.pathname === '/api/settings/room-skills') {
return handleRoomSkillsRoute(request, jsonResponse, deps);
}
if (url.pathname === '/api/settings/fast-mode') { if (url.pathname === '/api/settings/fast-mode') {
return handleFastModeRoute(request, jsonResponse, deps); return handleFastModeRoute(request, jsonResponse, deps);
} }
@@ -385,4 +403,5 @@ export type {
ModelConfigSnapshot, ModelConfigSnapshot,
MoaSettingsSnapshot, MoaSettingsSnapshot,
RuntimeInventorySnapshot, RuntimeInventorySnapshot,
RoomSkillSettingsSnapshot,
}; };