From fcd55a1c98174b8c78781ae742c04a3d46fc9f48 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 1 May 2026 23:21:24 +0900 Subject: [PATCH 01/39] add gated codex goals support --- runners/codex-runner/bun.lock | 16 ++-- runners/codex-runner/package.json | 4 +- runners/codex-runner/src/app-server-client.ts | 88 +++++++++++++++++-- runners/codex-runner/src/index.ts | 6 ++ .../test/app-server-client.test.ts | 87 ++++++++++++++++++ src/agent-runner-environment.test.ts | 73 +++++++++++++++ src/agent-runner-environment.ts | 10 +++ src/agent-runner.test.ts | 56 ++++++++++++ src/agent-runner.ts | 9 +- src/types.ts | 1 + 10 files changed, 333 insertions(+), 17 deletions(-) create mode 100644 runners/codex-runner/test/app-server-client.test.ts diff --git a/runners/codex-runner/bun.lock b/runners/codex-runner/bun.lock index efcfdf2..5e3c4a3 100644 --- a/runners/codex-runner/bun.lock +++ b/runners/codex-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ejclaw-codex-runner", "dependencies": { - "@openai/codex": "^0.124.0", + "@openai/codex": "0.128.0", "ejclaw-runners-shared": "file:../shared", }, "devDependencies": { @@ -15,19 +15,19 @@ }, }, "packages": { - "@openai/codex": ["@openai/codex@0.124.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.124.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.124.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.124.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.124.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.124.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.124.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-1EVAuPyAQZ8zIVMw3bPJ6a4R8ifLAZ7LGsOyknj5c2he9AFXVRCmWx12WrdZJ25wcBvOEKt1n1Zx+QAj0EVGbQ=="], + "@openai/codex": ["@openai/codex@0.128.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.128.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.128.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.128.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.128.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.128.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.128.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-+xp6ODmFfBNnexIWRHApEaPXot2j6gyM8A5we/5IS/uY4eYHj4arETct4hQ5M4eO+MK7JY3ZU4xhuobhlysr0A=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.124.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lnuqeAdl+RjPWsqVZ8rrPskRIOZmzlyr8e5q/wFVEnMPsm9dWgjRW1PKa84UmDSQVOuz9GObF28DEHFyC4A8NA=="], + "@openai/codex-darwin-arm64": ["@openai/codex@0.128.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-w+6zohfHx/kHBdles/CyFKaY57u9I3nK8QI9+NrdwMliKA0b7xn13yblRNkMpe09j6vL1oAWoxYsMOQ/vjBGug=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.124.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Br+VlL83IOu96Urw+mkZ1PQXLsQXGAYEH6kXNt4ULuVt7qAdQY2FKYwIgLLVLl0vpMk8qWxdfdVMqhiu2uCHlg=="], + "@openai/codex-darwin-x64": ["@openai/codex@0.128.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-SDbn6fO22Puy8xmMIbZi4f2znMrUEPwABApke4mo+4ihaauwuVjeqzXvW5SPJz5ty/bG11/mSupQgReT7T8BBw=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.124.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-QXafFVQJxPfU1LSCI5afuHsF5evKAcbQpFuKou0Kl241/HG/zYHdwoWj546zH844gJgegIPAxPf36+RSkUsbbA=="], + "@openai/codex-linux-arm64": ["@openai/codex@0.128.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-+SvH73H60qvCXFuQGP/EsmR//s1hHMBR22PvJkXvM/hdnTIGucx+JqRUjAWdmmQ1IU6j3kgwVvdLW/6ICB+M6w=="], - "@openai/codex-linux-x64": ["@openai/codex@0.124.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-cJ4QfQIrz2gkXVWephiGo9JDyD3qLKhvkTTLk7gI8rKd7MZpVXn9/1e7pZCQnJVA7Dk6ocA5G+sxnR78SoIejw=="], + "@openai/codex-linux-x64": ["@openai/codex@0.128.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-2lnSPA05CRRuKAzFW8BCmmNCSieDcToLwfC2ALLbBYilGLgzhRibjlDglK9F1BkEzfohSSWJu4PBbRu/aG60lQ=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.124.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-oDSI/CQh0kagBwWVPG9EiUBfHiY27ju3LPrjTVZg4VMpVJVNA31JTK8rHMZ6G/2gfNmOl6DMBA6+0ICxSkkshg=="], + "@openai/codex-win32-arm64": ["@openai/codex@0.128.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-ECJvsqmYFdA9pn42xxK3Odp/G16AjmBW0BglX8L0PwPjqbstbmlew9bfHf7xvL+SNfNl4NmyotW0+RNo1phgaA=="], - "@openai/codex-win32-x64": ["@openai/codex@0.124.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-t+lAwmCWwIWQKk6dKaYetRhQsmA+uDmQy1NLka1xAAv3PlNOH8iNz9Lsisr3qYkBR8Tf7tbQlt+pl9tw/A5mfA=="], + "@openai/codex-win32-x64": ["@openai/codex@0.128.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-k3jmUAFrzkUtvjGTXvSKjQqJLLlzjxp/VoHJDYedgmXUn6j70HxK38IwapzmnYfiBiTuzETvGwjXHzZgzKjhoQ=="], "@types/node": ["@types/node@22.19.15", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg=="], diff --git a/runners/codex-runner/package.json b/runners/codex-runner/package.json index 66788ca..4782b43 100644 --- a/runners/codex-runner/package.json +++ b/runners/codex-runner/package.json @@ -10,8 +10,8 @@ "start": "bun dist/index.js" }, "dependencies": { - "ejclaw-runners-shared": "file:../shared", - "@openai/codex": "^0.124.0" + "@openai/codex": "0.128.0", + "ejclaw-runners-shared": "file:../shared" }, "devDependencies": { "@types/node": "^22.10.7", diff --git a/runners/codex-runner/src/app-server-client.ts b/runners/codex-runner/src/app-server-client.ts index 333be05..a7f7aef 100644 --- a/runners/codex-runner/src/app-server-client.ts +++ b/runners/codex-runner/src/app-server-client.ts @@ -42,6 +42,53 @@ export interface CodexAppServerTurnResult { result: string | null; } +export interface CodexThreadGoal { + threadId: string; + objective: string; + status: string; + tokenBudget: number | null; + tokensUsed: number; + timeUsedSeconds: number; + createdAt: number; + updatedAt: number; +} + +export function buildCodexAppServerArgs(args: { + codexBin: string; + enableGoals?: boolean; +}): string[] { + return args.enableGoals === true + ? [args.codexBin, '--enable', 'goals', 'app-server'] + : [args.codexBin, 'app-server']; +} + +function isCodexThreadGoal(value: unknown): value is CodexThreadGoal { + if (!value || typeof value !== 'object') return false; + const goal = value as Partial; + return ( + typeof goal.threadId === 'string' && + typeof goal.objective === 'string' && + typeof goal.status === 'string' + ); +} + +function extractOptionalGoalResult(result: unknown): CodexThreadGoal | null { + const goal = (result as { goal?: unknown } | null)?.goal ?? null; + if (goal === null) return null; + if (!isCodexThreadGoal(goal)) { + throw new Error('Codex app-server returned an invalid goal payload.'); + } + return goal; +} + +function extractGoalResult(result: unknown, method: string): CodexThreadGoal { + const goal = extractOptionalGoalResult(result); + if (!goal) { + throw new Error(`Codex app-server ${method} did not return a goal.`); + } + return goal; +} + interface JsonRpcResponse { id: number; result?: unknown; @@ -79,12 +126,14 @@ export interface CodexAppServerClientOptions { cwd: string; env?: NodeJS.ProcessEnv; log: (message: string) => void; + enableGoals?: boolean; } export class CodexAppServerClient { private readonly cwd: string; private readonly env: NodeJS.ProcessEnv; private readonly log: (message: string) => void; + private readonly enableGoals: boolean; private readonly pending = new Map(); private readonly require = createRequire(import.meta.url); private nextId = 1; @@ -96,6 +145,7 @@ export class CodexAppServerClient { this.cwd = options.cwd; this.env = options.env || process.env; this.log = options.log; + this.enableGoals = options.enableGoals === true; } async start(): Promise { @@ -108,11 +158,18 @@ export class CodexAppServerClient { 'codex.js', ); - this.proc = spawn(process.execPath, [codexBin, 'app-server'], { - cwd: this.cwd, - env: this.env, - stdio: ['pipe', 'pipe', 'pipe'], - }); + this.proc = spawn( + process.execPath, + buildCodexAppServerArgs({ + codexBin, + enableGoals: this.enableGoals, + }), + { + cwd: this.cwd, + env: this.env, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ); this.proc.stdout.setEncoding('utf8'); this.proc.stdout.on('data', (chunk: string) => { @@ -312,6 +369,27 @@ export class CodexAppServerClient { return turnPromise; } + async threadGoalSet( + threadId: string, + objective: string, + ): Promise { + const result = await this.request('thread/goal/set', { + threadId, + objective, + }); + return extractGoalResult(result, 'thread/goal/set'); + } + + async threadGoalGet(threadId: string): Promise { + const result = await this.request('thread/goal/get', { threadId }); + return extractOptionalGoalResult(result); + } + + async threadGoalClear(threadId: string): Promise { + const result = await this.request('thread/goal/clear', { threadId }); + return (result as { cleared?: boolean } | null)?.cleared === true; + } + private handleStdoutLine(line: string): void { let message: JsonRpcResponse | JsonRpcNotification | JsonRpcServerRequest; try { diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index de859ae..252e492 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -52,6 +52,7 @@ interface RunnerInput { isScheduledTask?: boolean; assistantName?: string; agentType?: string; + codexGoals?: boolean; roomRoleContext?: RoomRoleContext; } @@ -95,6 +96,10 @@ function log(message: string): void { console.error(`[codex-runner] ${message}`); } +function isCodexGoalsEnabled(runnerInput: RunnerInput): boolean { + return runnerInput.codexGoals === true || process.env.CODEX_GOALS === 'true'; +} + async function readStdin(): Promise { return new Promise((resolve, reject) => { let data = ''; @@ -343,6 +348,7 @@ async function runAppServerSession( cwd: EFFECTIVE_CWD, env: clientEnv, log, + enableGoals: isCodexGoalsEnabled(runnerInput), }); await client.start(); diff --git a/runners/codex-runner/test/app-server-client.test.ts b/runners/codex-runner/test/app-server-client.test.ts new file mode 100644 index 0000000..0e7e481 --- /dev/null +++ b/runners/codex-runner/test/app-server-client.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + CodexAppServerClient, + buildCodexAppServerArgs, +} from '../src/app-server-client.js'; + +describe('codex app-server client goals', () => { + it('does not enable goals by default when spawning app-server', () => { + expect( + buildCodexAppServerArgs({ + codexBin: '/opt/codex/bin/codex.js', + }), + ).toEqual(['/opt/codex/bin/codex.js', 'app-server']); + }); + + it('adds the under-development goals feature only when explicitly enabled', () => { + expect( + buildCodexAppServerArgs({ + codexBin: '/opt/codex/bin/codex.js', + enableGoals: true, + }), + ).toEqual(['/opt/codex/bin/codex.js', '--enable', 'goals', 'app-server']); + }); + + it('wraps thread goal JSON-RPC methods with the upstream objective field', async () => { + const client = new CodexAppServerClient({ + cwd: '/repo', + log: () => undefined, + }); + const request = vi + .fn() + .mockResolvedValueOnce({ + goal: { + threadId: 'thread-1', + objective: 'ship the release', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + }, + }) + .mockResolvedValueOnce({ + goal: { + threadId: 'thread-1', + objective: 'ship the release', + status: 'active', + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: 1, + updatedAt: 1, + }, + }) + .mockResolvedValueOnce({ cleared: true }); + ( + client as unknown as { + request: (method: string, params?: unknown) => Promise; + } + ).request = request; + + await expect( + client.threadGoalSet('thread-1', 'ship the release'), + ).resolves.toMatchObject({ + threadId: 'thread-1', + objective: 'ship the release', + status: 'active', + }); + await expect(client.threadGoalGet('thread-1')).resolves.toMatchObject({ + objective: 'ship the release', + }); + await expect(client.threadGoalClear('thread-1')).resolves.toBe(true); + + expect(request).toHaveBeenNthCalledWith(1, 'thread/goal/set', { + threadId: 'thread-1', + objective: 'ship the release', + }); + expect(request).toHaveBeenNthCalledWith(2, 'thread/goal/get', { + threadId: 'thread-1', + }); + expect(request).toHaveBeenNthCalledWith(3, 'thread/goal/clear', { + threadId: 'thread-1', + }); + }); +}); diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 85390ba..f972a02 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -370,6 +370,79 @@ describe('prepareGroupEnvironment codex auth handling', () => { }); }); +describe('prepareGroupEnvironment Codex goals handling', () => { + let tempRoot: string; + let previousCwd: string; + let previousCodexGoals: string | undefined; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-env-goals-')); + previousCwd = process.cwd(); + previousCodexGoals = process.env.CODEX_GOALS; + process.chdir(tempRoot); + process.env.EJ_TEST_ROOT = tempRoot; + process.env.EJ_TEST_HOME = path.join(tempRoot, 'home'); + delete process.env.CODEX_GOALS; + + fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), { + recursive: true, + }); + + mockReadEnvFile.mockReset(); + mockGetActiveCodexAuthPath.mockReset(); + vi.mocked(config.isReviewService).mockReturnValue(false); + vi.mocked(serviceRouting.hasReviewerLease).mockReturnValue(false); + vi.mocked(serviceRouting.getEffectiveChannelLease).mockReturnValue({ + chat_jid: 'dc:test', + owner_service_id: 'claude', + reviewer_service_id: 'codex-main', + arbiter_service_id: null, + owner_failover_active: false, + activated_at: null, + reason: null, + explicit: false, + }); + }); + + afterEach(() => { + process.chdir(previousCwd); + delete process.env.EJ_TEST_ROOT; + delete process.env.EJ_TEST_HOME; + if (previousCodexGoals) process.env.CODEX_GOALS = previousCodexGoals; + else delete process.env.CODEX_GOALS; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('keeps Codex goals disabled by default and enables them only via opt-in config', () => { + mockReadEnvFile.mockReturnValue({}); + + const defaultPrepared = prepareGroupEnvironment(group, false, 'dc:test'); + expect(defaultPrepared.env.CODEX_GOALS).toBeUndefined(); + + const enabledPrepared = prepareGroupEnvironment( + { + ...group, + agentConfig: { + codexGoals: true, + }, + }, + false, + 'dc:test', + ); + expect(enabledPrepared.env.CODEX_GOALS).toBe('true'); + }); + + it('allows CODEX_GOALS env opt-in for Codex runner sessions', () => { + mockReadEnvFile.mockReturnValue({ + CODEX_GOALS: 'true', + }); + + const prepared = prepareGroupEnvironment(group, false, 'dc:test'); + + expect(prepared.env.CODEX_GOALS).toBe('true'); + }); +}); + describe('prepareReadonlySessionEnvironment codex compatibility', () => { let tempRoot: string; let previousCwd: string; diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 777b3a0..433b62f 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -319,6 +319,15 @@ function prepareCodexSessionEnvironment(args: { process.env.CODEX_EFFORT; if (codexEffort) args.env.CODEX_EFFORT = codexEffort; + const codexGoals = + args.group.agentConfig?.codexGoals ?? + (args.envVars.CODEX_GOALS === 'true' || process.env.CODEX_GOALS === 'true'); + if (codexGoals) { + args.env.CODEX_GOALS = 'true'; + } else { + delete args.env.CODEX_GOALS; + } + const sessionCodexDir = path.join(args.sessionRootDir, '.codex'); syncHostCodexSessionFiles(sessionCodexDir); @@ -527,6 +536,7 @@ export function prepareGroupEnvironment( 'CLAUDE_EFFORT', 'CODEX_MODEL', 'CODEX_EFFORT', + 'CODEX_GOALS', ]); const env = buildBaseRunnerEnv({ diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 55320d7..962c85e 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -680,3 +680,59 @@ OUROBOROS_LLM_BACKEND = "codex" ); }); }); + +describe('agent-runner Codex goals', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + fakeProc = createFakeProcess(); + vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { + const str = String(p); + return ( + str.includes('dist/index.js') || + str.includes('dist/ipc-mcp-stdio.js') || + str.endsWith('/.codex/config.toml') + ); + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes Codex goal opt-in config through env and runner stdin payload', async () => { + let stdinPayload = ''; + fakeProc.stdin.on('data', (chunk) => { + stdinPayload += chunk.toString(); + }); + + const codexGroup: RegisteredGroup = { + ...testGroup, + agentType: 'codex', + agentConfig: { + codexGoals: true, + }, + }; + + const resultPromise = runAgentProcess( + codexGroup, + testInput, + () => {}, + async () => {}, + ); + + fakeProc.emit('close', 0); + const result = await resultPromise; + expect(result.status).toBe('success'); + + expect(JSON.parse(stdinPayload)).toMatchObject({ + prompt: 'Hello', + codexGoals: true, + }); + + const spawnEnv = vi.mocked(spawn).mock.calls.at(-1)?.[2]?.env as + | Record + | undefined; + expect(spawnEnv?.CODEX_GOALS).toBe('true'); + }); +}); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index f885f87..e64c239 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -34,6 +34,7 @@ export interface AgentInput { useTaskScopedSession?: boolean; assistantName?: string; agentType?: 'claude-code' | 'codex'; + codexGoals?: boolean; roomRoleContext?: RoomRoleContext; } @@ -150,13 +151,17 @@ export async function runAgentProcess( onProcess(proc, processName, env.EJCLAW_IPC_DIR); - proc.stdin.write(JSON.stringify(input)); + const runnerInput: AgentInput = { + ...input, + ...(group.agentConfig?.codexGoals === true ? { codexGoals: true } : {}), + }; + proc.stdin.write(JSON.stringify(runnerInput)); proc.stdin.end(); runSpawnedAgentProcess({ proc, group, - input, + input: runnerInput, processName, logsDir, startTime, diff --git a/src/types.ts b/src/types.ts index 8233453..ce35bfe 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,7 @@ export interface AgentConfig { // Per-group model/effort overrides (take precedence over global env vars) codexModel?: string; codexEffort?: string; + codexGoals?: boolean; claudeModel?: string; claudeEffort?: string; claudeThinking?: 'adaptive' | 'enabled' | 'disabled'; From 53a00fa93017e24bf3a36b658cc108acd6fe0f57 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 1 May 2026 23:34:18 +0900 Subject: [PATCH 02/39] sync README SDK versions --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 55cf0ba..3d7217f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # EJClaw ![Version](https://img.shields.io/badge/version-0.2.3-blue) -![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.101-blueviolet) -![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.120.0-green) +![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.114-blueviolet) +![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.128.0-green) ![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black) ![Discord](https://img.shields.io/badge/Discord-Tribunal-5865F2?logo=discord&logoColor=white) @@ -115,6 +115,11 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho - Codex CLI - Discord 봇 토큰 3개(owner / reviewer / arbiter) +현재 runner 번들 기준 버전: + +- Claude Agent SDK: `@anthropic-ai/claude-agent-sdk@0.2.114` +- Codex SDK/CLI: `@openai/codex@0.128.0` + ### 설치 ```bash From 5b544b69c6d6e08a16ccbf09a0dd8eb0273f43de Mon Sep 17 00:00:00 2001 From: ejclaw Date: Fri, 1 May 2026 23:45:10 +0900 Subject: [PATCH 03/39] bump claude agent sdk --- README.md | 4 ++-- runners/agent-runner/bun.lock | 20 ++++++++++---------- runners/agent-runner/package.json | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 3d7217f..1cdc3cd 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # EJClaw ![Version](https://img.shields.io/badge/version-0.2.3-blue) -![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.114-blueviolet) +![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.126-blueviolet) ![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.128.0-green) ![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black) ![Discord](https://img.shields.io/badge/Discord-Tribunal-5865F2?logo=discord&logoColor=white) @@ -117,7 +117,7 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho 현재 runner 번들 기준 버전: -- Claude Agent SDK: `@anthropic-ai/claude-agent-sdk@0.2.114` +- Claude Agent SDK: `@anthropic-ai/claude-agent-sdk@0.2.126` - Codex SDK/CLI: `@openai/codex@0.128.0` ### 설치 diff --git a/runners/agent-runner/bun.lock b/runners/agent-runner/bun.lock index b550b8c..ed607ce 100644 --- a/runners/agent-runner/bun.lock +++ b/runners/agent-runner/bun.lock @@ -5,7 +5,7 @@ "": { "name": "ejclaw-agent-runner", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.114", + "@anthropic-ai/claude-agent-sdk": "0.2.126", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", @@ -18,23 +18,23 @@ }, }, "packages": { - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.126", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.126", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.126", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.126", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.126" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-4ZrVu0XUEwNG6wxvsLgppRAmSfAf3oeEMEUPhgazb0AXUUe/7W8MxwZKJWOffqSLWaNYzOt3ZCIL7NJY6toqWw=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.126", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JFlJBbeAlx7Ic5s4lGUN9SppobryXk/lIqPCvhp6KrJTQIerh3MIBzxsVIJ0MaDut7jVni/oYgsvDni7NIyqHA=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.126", "", { "os": "darwin", "cpu": "x64" }, "sha512-J8BpMj16NK9FUaG3HnHSivyp4Xww9DKWHiC8QSHT9oiT8pH5IG7nl0jxyjIq/lY79evlTY+ubgDVWlMUhUAN/g=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.126", "", { "os": "linux", "cpu": "arm64" }, "sha512-LM+mnfQsgI+1i5mYZwIPDDf14NGBu5wbhzm5U8P11dCa2p8sXmKoWpkbO16BFM2NxeW44I/RXCxE5qFsbz4zcg=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.126", "", { "os": "linux", "cpu": "arm64" }, "sha512-GO0BnIUw3LQ3XAy+nipAabkN0GwQGPhHB6ITI4XLoR99fLHB3TA6WfyvTf0fnpxd25A+c/+UsAoxz4zBQaHlhA=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.126", "", { "os": "linux", "cpu": "x64" }, "sha512-yaOTDcYCdscxC0LKg9w8IwSa5g+993WggFZJBTZpqvflA2+WMQeTarDnKlsFTCw9XUZkL8XZeBALYJGx0HutuA=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.126", "", { "os": "linux", "cpu": "x64" }, "sha512-ByJGO0+mu7EplxSFSCIHd7QWsXdrF3qgtzQ177o/j+oSppLoqR1ot5ktf8aw5oR3CC5lFHg4tqd6TnneQpEoIg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.126", "", { "os": "win32", "cpu": "arm64" }, "sha512-gv3MOsOBkCx3LajOOIjD7AKsOtz/qNHsS2oshGt2GVoy7JA3XbCDeCetDjM6SorV4SE+7F/IH0UJdXe5ejI/Zg=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.126", "", { "os": "win32", "cpu": "x64" }, "sha512-oRV75HwyoOd1/t5+kipAM2g62CaElpKGvSBx3Ys4lCwCiFUyOnmet/O+hRXENsY6ShDeQZEcJL2UWljr2d5NQw=="], "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], diff --git a/runners/agent-runner/package.json b/runners/agent-runner/package.json index 1727ebd..e75ff9b 100644 --- a/runners/agent-runner/package.json +++ b/runners/agent-runner/package.json @@ -10,7 +10,7 @@ "start": "bun dist/index.js" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.2.114", + "@anthropic-ai/claude-agent-sdk": "0.2.126", "@modelcontextprotocol/sdk": "^1.12.1", "cron-parser": "^5.0.0", "ejclaw-runners-shared": "file:../shared", From e6f417c3433ec9aed3784e5c9a28969e8fe1adb2 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 00:06:26 +0900 Subject: [PATCH 04/39] add codex goals settings toggle --- apps/dashboard/src/SettingsPanel.test.ts | 1 + apps/dashboard/src/SettingsPanel.tsx | 103 ++++++++++++++++++++++ apps/dashboard/src/api.ts | 32 +++++++ src/settings-store.test.ts | 45 ++++++++++ src/settings-store.ts | 34 +++++++ src/web-dashboard-settings-routes.test.ts | 22 +++++ src/web-dashboard-settings-routes.ts | 28 ++++++ 7 files changed, 265 insertions(+) create mode 100644 src/settings-store.test.ts diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index ec7aa2f..42ec421 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -34,6 +34,7 @@ describe('SettingsPanel', () => { expect(html).toContain('모델'); expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); + expect(html).toContain('Codex 실험 기능'); expect(html).toContain('불러오는 중'); expect(html).toContain('Claude'); expect(html).toContain('계정'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 978039c..eed5ddb 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -3,17 +3,20 @@ import { useEffect, useState } from 'react'; import { type ClaudeAccountSummary, type CodexAccountSummary, + type CodexFeatureSnapshot, type FastModeSnapshot, type ModelConfigSnapshot, type ModelRoleConfig, addClaudeAccount, deleteAccount, fetchAccounts, + fetchCodexFeatures, fetchFastMode, fetchModelConfig, refreshAllCodexAccounts as refreshAllCodexAccountsApi, refreshCodexAccount as refreshCodexAccountApi, setCurrentCodexAccount as setCurrentCodexAccountApi, + updateCodexFeatures, updateFastMode, updateModels, } from './api'; @@ -82,6 +85,8 @@ export function SettingsPanel({ + + ); @@ -302,6 +307,104 @@ function FastModeSettings() { ); } +function CodexFeatureSettings({ + onRestartStack, +}: { + onRestartStack: () => void; +}) { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [savedAt, setSavedAt] = useState(null); + + useEffect(() => { + let cancelled = false; + fetchCodexFeatures() + .then((snapshot) => { + if (cancelled) return; + setState(snapshot); + setError(null); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : String(err)); + }); + return () => { + cancelled = true; + }; + }, []); + + async function toggleGoals() { + if (!state) return; + const previous = state; + const optimistic = { ...state, goals: !state.goals }; + setState(optimistic); + setBusy(true); + setError(null); + try { + const fresh = await updateCodexFeatures({ goals: optimistic.goals }); + setState(fresh); + setSavedAt(Date.now()); + } catch (err) { + setState(previous); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + } + + return ( +
+

Codex 실험 기능

+ {error ?

{error}

: null} + {!state ? ( +

불러오는 중…

+ ) : ( + <> + +
+ {savedAt ? ( + + 저장됨. 적용하려면 스택 재시작 필요. + + ) : null} + +
+ + )} +
+ ); +} + function formatExpiry( iso: string | null, ): { label: string; cls: string } | null { diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index b317573..baf96e6 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -388,6 +388,10 @@ export interface FastModeSnapshot { claude: boolean; } +export interface CodexFeatureSnapshot { + goals: boolean; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -538,6 +542,34 @@ export async function updateFastMode( return (await response.json()) as FastModeSnapshot; } +export async function fetchCodexFeatures(): Promise { + return fetchJson('/api/settings/codex-features'); +} + +export async function updateCodexFeatures( + input: Partial, +): Promise { + const response = await fetch('/api/settings/codex-features', { + method: 'PUT', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update codex features failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as CodexFeatureSnapshot; +} + export async function fetchMoaSettings(): Promise { return fetchJson('/api/settings/moa'); } diff --git a/src/settings-store.test.ts b/src/settings-store.test.ts new file mode 100644 index 0000000..2f965e9 --- /dev/null +++ b/src/settings-store.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { getCodexFeatures, updateCodexFeatures } from './settings-store.js'; + +describe('settings-store Codex features', () => { + let tempDir: string; + let previousCwd: string; + let previousCodexGoals: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-settings-')); + previousCwd = process.cwd(); + previousCodexGoals = process.env.CODEX_GOALS; + delete process.env.CODEX_GOALS; + process.chdir(tempDir); + }); + + afterEach(() => { + process.chdir(previousCwd); + if (previousCodexGoals === undefined) { + delete process.env.CODEX_GOALS; + } else { + process.env.CODEX_GOALS = previousCodexGoals; + } + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('stores the Codex goals opt-in in the EJClaw .env file', () => { + expect(getCodexFeatures()).toEqual({ goals: false }); + + expect(updateCodexFeatures({ goals: true })).toEqual({ goals: true }); + expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain( + 'CODEX_GOALS=true', + ); + + expect(updateCodexFeatures({ goals: false })).toEqual({ goals: false }); + expect(fs.readFileSync(path.join(tempDir, '.env'), 'utf-8')).toContain( + 'CODEX_GOALS=false', + ); + }); +}); diff --git a/src/settings-store.ts b/src/settings-store.ts index e861cfa..3ecd232 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -56,6 +56,10 @@ export interface FastModeSnapshot { claude: boolean; } +export interface CodexFeatureSnapshot { + goals: boolean; +} + const ROLE_KEYS = ['OWNER', 'REVIEWER', 'ARBITER'] as const; function envFilePath(): string { @@ -634,6 +638,36 @@ export function updateFastMode( return getFastMode(); } +function readCodexGoals(): boolean { + return readEnvOrProcess('CODEX_GOALS') === 'true'; +} + +function writeCodexGoals(value: boolean): void { + const file = envFilePath(); + const content = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : ''; + const updated = setOrInsertEnvLine( + content, + 'CODEX_GOALS', + value ? 'true' : 'false', + ); + const tempPath = `${file}.tmp`; + fs.writeFileSync(tempPath, updated, { mode: 0o600 }); + fs.renameSync(tempPath, file); +} + +export function getCodexFeatures(): CodexFeatureSnapshot { + return { + goals: readCodexGoals(), + }; +} + +export function updateCodexFeatures( + input: Partial, +): CodexFeatureSnapshot { + if (typeof input.goals === 'boolean') writeCodexGoals(input.goals); + return getCodexFeatures(); +} + export function removeAccountDirectory( provider: 'claude' | 'codex', index: number, diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index 3b29fc2..84ee2af 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -7,6 +7,7 @@ import { import type { ClaudeAccountSummary, CodexAccountSummary, + CodexFeatureSnapshot, FastModeSnapshot, ModelConfigSnapshot, } from './settings-store.js'; @@ -42,6 +43,7 @@ const modelConfig: ModelConfigSnapshot = { }; const fastMode: FastModeSnapshot = { codex: true, claude: false }; +const codexFeatures: CodexFeatureSnapshot = { goals: false }; const moaSettings: MoaSettingsSnapshot = { enabled: true, @@ -107,6 +109,7 @@ function makeDeps( responseLength: 2, }), getActiveCodexSettingsIndex: () => 1, + getCodexFeatures: () => codexFeatures, getFastMode: () => fastMode, getModelConfig: () => modelConfig, getMoaSettings: () => moaSettings, @@ -116,6 +119,7 @@ function makeDeps( refreshCodexAccount: async () => makeCodexAccount(), removeAccountDirectory: () => undefined, setActiveCodexSettingsIndex: () => undefined, + updateCodexFeatures: () => codexFeatures, updateFastMode: () => fastMode, updateModelConfig: () => modelConfig, updateMoaSettings: () => moaSettings, @@ -155,6 +159,10 @@ describe('web dashboard settings routes', () => { expect(mode?.status).toBe(200); await expect(mode?.json()).resolves.toEqual(fastMode); + const features = await route('/api/settings/codex-features'); + expect(features?.status).toBe(200); + await expect(features?.json()).resolves.toEqual(codexFeatures); + const moa = await route('/api/settings/moa'); expect(moa?.status).toBe(200); const moaJson = (await moa?.json()) as MoaSettingsSnapshot; @@ -187,6 +195,10 @@ describe('web dashboard settings routes', () => { calls.push(`models:${JSON.stringify(input)}`); return modelConfig; }, + updateCodexFeatures: (input) => { + calls.push(`codex-features:${JSON.stringify(input)}`); + return { goals: input.goals === true }; + }, updateMoaSettings: (input) => { calls.push(`moa:${JSON.stringify(input)}`); return moaSettings; @@ -230,6 +242,15 @@ describe('web dashboard settings routes', () => { codexCurrentIndex: 1, }); + const goals = await route( + '/api/settings/codex-features', + 'PATCH', + { goals: true }, + deps, + ); + expect(goals?.status).toBe(200); + await expect(goals?.json()).resolves.toEqual({ goals: true }); + const moa = await route( '/api/settings/moa', 'PATCH', @@ -255,6 +276,7 @@ describe('web dashboard settings routes', () => { 'add:claude-token', 'delete:codex:4', 'current:4', + 'codex-features:{"goals":true}', 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', ]); }); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index b0f268a..fcec7fe 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -1,6 +1,7 @@ import { addClaudeAccountFromToken, getActiveCodexSettingsIndex, + getCodexFeatures, getFastMode, getModelConfig, listClaudeAccounts, @@ -9,9 +10,11 @@ import { refreshCodexAccount, removeAccountDirectory, setActiveCodexSettingsIndex, + updateCodexFeatures, updateFastMode, updateModelConfig, type ClaudeAccountSummary, + type CodexFeatureSnapshot, type CodexAccountSummary, type FastModeSnapshot, type ModelConfigSnapshot, @@ -33,6 +36,7 @@ export interface SettingsRouteDependencies { addClaudeAccountFromToken: typeof addClaudeAccountFromToken; checkMoaModel: typeof checkMoaModel; getActiveCodexSettingsIndex: typeof getActiveCodexSettingsIndex; + getCodexFeatures: typeof getCodexFeatures; getFastMode: typeof getFastMode; getModelConfig: typeof getModelConfig; getMoaSettings: typeof getMoaSettings; @@ -42,6 +46,7 @@ export interface SettingsRouteDependencies { refreshCodexAccount: typeof refreshCodexAccount; removeAccountDirectory: typeof removeAccountDirectory; setActiveCodexSettingsIndex: typeof setActiveCodexSettingsIndex; + updateCodexFeatures: typeof updateCodexFeatures; updateFastMode: typeof updateFastMode; updateModelConfig: typeof updateModelConfig; updateMoaSettings: typeof updateMoaSettings; @@ -58,6 +63,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { addClaudeAccountFromToken, checkMoaModel, getActiveCodexSettingsIndex, + getCodexFeatures, getFastMode, getModelConfig, getMoaSettings, @@ -67,6 +73,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { refreshCodexAccount, removeAccountDirectory, setActiveCodexSettingsIndex, + updateCodexFeatures, updateFastMode, updateModelConfig, updateMoaSettings, @@ -133,6 +140,23 @@ async function handleFastModeRoute( } } +async function handleCodexFeaturesRoute( + request: Request, + jsonResponse: JsonResponse, + deps: SettingsRouteDependencies, +): Promise { + if (readMethod(request.method)) return jsonResponse(deps.getCodexFeatures()); + if (request.method !== 'PUT' && request.method !== 'PATCH') return null; + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + try { + return jsonResponse(deps.updateCodexFeatures(body)); + } catch (err) { + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } +} + async function handleMoaSettingsRoute( request: Request, jsonResponse: JsonResponse, @@ -299,6 +323,9 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/codex-features') { + return handleCodexFeaturesRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/moa') { return handleMoaSettingsRoute(request, jsonResponse, deps); } @@ -335,6 +362,7 @@ export async function handleSettingsRoute({ export type { ClaudeAccountSummary, CodexAccountSummary, + CodexFeatureSnapshot, FastModeSnapshot, ModelConfigSnapshot, MoaSettingsSnapshot, From 422ad331bb7920fb2d2944fd82cfb9d1c65e8e2f Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 01:59:38 +0900 Subject: [PATCH 05/39] reuse status dashboard message and simplify settings actions --- apps/dashboard/src/MoaSettingsPanel.tsx | 30 +----- apps/dashboard/src/SettingsPanel.test.ts | 2 + apps/dashboard/src/SettingsPanel.tsx | 113 +++++++++-------------- apps/dashboard/src/styles.css | 33 +++++++ src/status-dashboard.test.ts | 30 ++++++ src/status-dashboard.ts | 57 +++++++++++- src/unified-dashboard.test.ts | 27 ++++++ src/unified-dashboard.ts | 41 +++++++- 8 files changed, 230 insertions(+), 103 deletions(-) create mode 100644 src/status-dashboard.test.ts diff --git a/apps/dashboard/src/MoaSettingsPanel.tsx b/apps/dashboard/src/MoaSettingsPanel.tsx index 9b473e0..18428ea 100644 --- a/apps/dashboard/src/MoaSettingsPanel.tsx +++ b/apps/dashboard/src/MoaSettingsPanel.tsx @@ -9,11 +9,7 @@ import { updateMoaSettings, } from './api'; -export function MoaSettingsPanel({ - onRestartStack, -}: { - onRestartStack: () => void; -}) { +export function MoaSettingsPanel() { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); const [apiKeys, setApiKeys] = useState>({}); @@ -135,7 +131,6 @@ export function MoaSettingsPanel({ setApiKeys((prev) => ({ ...prev, [name]: value })) } onModelChange={setModel} - onRestartStack={onRestartStack} onSave={save} onTest={testModel} onToggle={() => @@ -158,7 +153,6 @@ function MoaSettingsContent({ draft, onApiKeyChange, onModelChange, - onRestartStack, onSave, onTest, onToggle, @@ -174,7 +168,6 @@ function MoaSettingsContent({ name: string, patch: Partial, ) => void; - onRestartStack: () => void; onSave: () => Promise; onTest: (name: string) => Promise; onToggle: () => void; @@ -206,7 +199,6 @@ function MoaSettingsContent({ busy={busy} checking={checking} dirty={dirty} - onRestartStack={onRestartStack} onSave={onSave} savedAt={savedAt} /> @@ -256,14 +248,12 @@ function MoaSettingsActions({ busy, checking, dirty, - onRestartStack, onSave, savedAt, }: { busy: boolean; checking: string | null; dirty: boolean; - onRestartStack: () => void; onSave: () => Promise; savedAt: number | null; }) { @@ -275,29 +265,13 @@ function MoaSettingsActions({ onClick={() => void onSave()} type="button" > - {busy ? '저장 중…' : '저장'} + {busy ? '저장 중…' : 'MoA 저장'} {savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null} - ); } diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index 42ec421..db03e42 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -35,10 +35,12 @@ describe('SettingsPanel', () => { expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); expect(html).toContain('Codex 실험 기능'); + expect(html).toContain('변경 적용'); expect(html).toContain('불러오는 중'); expect(html).toContain('Claude'); expect(html).toContain('계정'); expect(html).toContain('전체 갱신'); expect(html).toContain('스택 재시작'); + expect(html.match(/class="settings-restart"/g)).toHaveLength(1); }); }); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index eed5ddb..2028d3e 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -79,20 +79,51 @@ export function SettingsPanel({ - + - + + + - + - + ); } -function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { +function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) { + return ( +
+
+ 변경 적용 + + 모델, MoA, Codex 실험 기능, 계정 변경은 저장 후 스택 재시작으로 + 적용됩니다. + +
+ +
+ ); +} + +function ModelSettings() { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); const [busy, setBusy] = useState(false); @@ -193,29 +224,13 @@ function ModelSettings({ onRestartStack }: { onRestartStack: () => void }) { onClick={() => void save()} type="button" > - {busy ? '저장 중…' : '저장'} + {busy ? '저장 중…' : '모델 저장'} {savedAt && !dirty ? ( 저장됨. 적용하려면 스택 재시작 필요. ) : null} - )} @@ -307,11 +322,7 @@ function FastModeSettings() { ); } -function CodexFeatureSettings({ - onRestartStack, -}: { - onRestartStack: () => void; -}) { +function CodexFeatureSettings() { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -376,29 +387,11 @@ function CodexFeatureSettings({ type="checkbox" /> -
- {savedAt ? ( - - 저장됨. 적용하려면 스택 재시작 필요. - - ) : null} - -
+ {savedAt ? ( + + 저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요. + + ) : null} )} @@ -436,7 +429,7 @@ function formatExpiry( }; } -function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { +function AccountSettings() { const [data, setData] = useState(null); const [busy, setBusy] = useState(false); const [perRowBusy, setPerRowBusy] = useState(null); @@ -561,24 +554,6 @@ function AccountSettings({ onRestartStack }: { onRestartStack: () => void }) { onSwitch={(index) => void handleSwitchCodex(index)} perRowBusy={perRowBusy} /> -
- -
); } diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 27b18ef..4ec0fcf 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -696,6 +696,39 @@ button:disabled { letter-spacing: 0.05em; } +.settings-apply-bar { + position: sticky; + top: 72px; + z-index: 5; + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; + padding: 12px; + border: 1px solid rgba(214, 130, 88, 0.32); + border-radius: 10px; + background: + linear-gradient(135deg, rgba(214, 130, 88, 0.14), transparent 55%), + rgba(19, 24, 22, 0.94); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2); +} + +.settings-apply-bar > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.settings-apply-bar strong { + color: var(--bg-ink); + font-size: 13px; +} + +.settings-apply-bar small { + color: var(--muted); + font-size: 11px; +} + .settings-row-inline { grid-template-columns: 80px minmax(0, 1fr) 100px; display: grid; diff --git a/src/status-dashboard.test.ts b/src/status-dashboard.test.ts new file mode 100644 index 0000000..d48372d --- /dev/null +++ b/src/status-dashboard.test.ts @@ -0,0 +1,30 @@ +import fs from 'fs'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { CACHE_DIR } from './config.js'; +import { + readDashboardStatusMessageId, + writeDashboardStatusMessageId, +} from './status-dashboard.js'; + +afterEach(() => { + const messagesDir = path.join(CACHE_DIR, 'status-dashboard', 'messages'); + fs.rmSync(path.join(messagesDir, 'test-status-channel.json'), { + force: true, + }); +}); + +describe('dashboard status message id state', () => { + it('persists the tracked Discord status message id across restarts', async () => { + expect(readDashboardStatusMessageId('test-status-channel')).toBeNull(); + + writeDashboardStatusMessageId('test-status-channel', '1499810000000000000'); + + expect(readDashboardStatusMessageId('test-status-channel')).toBe( + '1499810000000000000', + ); + expect(readDashboardStatusMessageId('other-channel')).toBeNull(); + }); +}); diff --git a/src/status-dashboard.ts b/src/status-dashboard.ts index 59d77ba..08b057b 100644 --- a/src/status-dashboard.ts +++ b/src/status-dashboard.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { CACHE_DIR } from './config.js'; -import { writeJsonFile } from './utils.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; import type { GroupStatus } from './group-queue.js'; import type { AgentType } from './types.js'; @@ -37,6 +37,61 @@ export interface StatusSnapshot { } const STATUS_SNAPSHOT_DIR = path.join(CACHE_DIR, 'status-dashboard'); +const STATUS_MESSAGE_STATE_DIR = path.join(STATUS_SNAPSHOT_DIR, 'messages'); + +interface StatusMessageState { + messageId: string; + statusChannelId: string; + updatedAt: string; +} + +function sanitizeStatusChannelId(statusChannelId: string): string { + return statusChannelId.replace(/[^a-zA-Z0-9_-]/g, '_'); +} + +function getStatusMessageStatePath(statusChannelId: string): string { + return path.join( + STATUS_MESSAGE_STATE_DIR, + `${sanitizeStatusChannelId(statusChannelId)}.json`, + ); +} + +export function readDashboardStatusMessageId( + statusChannelId: string, +): string | null { + if (!statusChannelId) return null; + const parsed = readJsonFile( + getStatusMessageStatePath(statusChannelId), + ); + if ( + parsed?.statusChannelId !== statusChannelId || + typeof parsed.messageId !== 'string' || + parsed.messageId.trim() === '' + ) { + return null; + } + return parsed.messageId; +} + +export function writeDashboardStatusMessageId( + statusChannelId: string, + messageId: string, +): void { + if (!statusChannelId || !messageId) return; + fs.mkdirSync(STATUS_MESSAGE_STATE_DIR, { recursive: true }); + const targetPath = getStatusMessageStatePath(statusChannelId); + const tempPath = `${targetPath}.tmp`; + writeJsonFile( + tempPath, + { + messageId, + statusChannelId, + updatedAt: new Date().toISOString(), + } satisfies StatusMessageState, + true, + ); + fs.renameSync(tempPath, targetPath); +} export function writeStatusSnapshot(snapshot: StatusSnapshot): void { fs.mkdirSync(STATUS_SNAPSHOT_DIR, { recursive: true }); diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index 6e77084..fbc7f0d 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -5,6 +5,7 @@ import { buildWebUsageRowsForSnapshot, formatStatusHeader, renderUsageTable, + shouldPurgeDashboardChannelOnStart, summarizeWatcherTasks, } from './unified-dashboard.js'; @@ -61,6 +62,32 @@ describe('formatStatusHeader', () => { }); }); +describe('shouldPurgeDashboardChannelOnStart', () => { + it('keeps the existing status message when a stored message id exists', () => { + expect( + shouldPurgeDashboardChannelOnStart({ + purgeOnStart: true, + storedMessageId: 'status-message-1', + }), + ).toBe(false); + }); + + it('purges only when explicitly requested and no stored message id exists', () => { + expect( + shouldPurgeDashboardChannelOnStart({ + purgeOnStart: true, + storedMessageId: null, + }), + ).toBe(true); + expect( + shouldPurgeDashboardChannelOnStart({ + purgeOnStart: false, + storedMessageId: null, + }), + ).toBe(false); + }); +}); + describe('renderUsageTable', () => { const claudeRow: UsageRow = { name: 'Claude pro', diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 86113ba..9aac39f 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -52,7 +52,9 @@ import type { GroupQueue } from './group-queue.js'; import { logger } from './logger.js'; import { isWatchCiTask } from './task-watch-status.js'; import { + readDashboardStatusMessageId, readStatusSnapshots, + writeDashboardStatusMessageId, writeStatusSnapshot, } from './status-dashboard.js'; import type { @@ -169,6 +171,13 @@ export async function purgeDashboardChannel( } } +export function shouldPurgeDashboardChannelOnStart(args: { + purgeOnStart?: boolean; + storedMessageId: string | null; +}): boolean { + return args.purgeOnStart === true && !args.storedMessageId; +} + async function refreshChannelMeta( opts: UnifiedDashboardOptions, ): Promise { @@ -708,8 +717,17 @@ export async function startUnifiedDashboard( const isRenderer = opts.serviceAgentType === 'claude-code'; const statusJid = `dc:${opts.statusChannelId}`; + if (isRenderer) { + statusMessageId = readDashboardStatusMessageId(opts.statusChannelId); + } - if (isRenderer && opts.purgeOnStart) { + if ( + isRenderer && + shouldPurgeDashboardChannelOnStart({ + purgeOnStart: opts.purgeOnStart, + storedMessageId: statusMessageId, + }) + ) { await purgeDashboardChannel(opts); } @@ -746,15 +764,28 @@ export async function startUnifiedDashboard( }, 'Dashboard content empty, skipping render', ); - statusMessageId = null; return; } if (statusMessageId && channel.editMessage) { - await channel.editMessage(statusJid, statusMessageId, content); - } else if (channel.sendAndTrack) { + try { + await channel.editMessage(statusJid, statusMessageId, content); + writeDashboardStatusMessageId(opts.statusChannelId, statusMessageId); + } catch (err) { + logger.warn( + { err, messageId: statusMessageId }, + 'Dashboard status message edit failed; sending a fresh tracked message', + ); + statusMessageId = null; + } + } + + if (!statusMessageId && channel.sendAndTrack) { const id = await channel.sendAndTrack(statusJid, content); - if (id) statusMessageId = id; + if (id) { + statusMessageId = id; + writeDashboardStatusMessageId(opts.statusChannelId, id); + } } if (!dashboardUpdateLogged) { logger.info( From 7ee0aab5e44ce4e195fd33ed77a4f36d3ceb6814 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 02:21:36 +0900 Subject: [PATCH 06/39] refine settings page UX --- apps/dashboard/src/MoaSettingsPanel.tsx | 10 +- apps/dashboard/src/SettingsPanel.test.ts | 4 + apps/dashboard/src/SettingsPanel.tsx | 204 +++++++++++--- apps/dashboard/src/styles.css | 324 ++++++++++++++++++++--- 4 files changed, 457 insertions(+), 85 deletions(-) diff --git a/apps/dashboard/src/MoaSettingsPanel.tsx b/apps/dashboard/src/MoaSettingsPanel.tsx index 18428ea..8ff2f32 100644 --- a/apps/dashboard/src/MoaSettingsPanel.tsx +++ b/apps/dashboard/src/MoaSettingsPanel.tsx @@ -113,8 +113,12 @@ export function MoaSettingsPanel() { Object.values(apiKeys).some((value) => value.trim())); return ( -
-

MoA 참조 모델

+
+
+ Arbiter references +

MoA 참조 모델

+

Kimi, GLM 같은 외부 참조 모델을 켜고 연결 상태를 바로 확인합니다.

+
{error ?

{error}

: null} {!draft ? (

@@ -269,7 +273,7 @@ function MoaSettingsActions({ {savedAt && !dirty ? ( - 저장됨. 적용하려면 스택 재시작 필요. + 저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요. ) : null} diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index db03e42..d9882af 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -21,6 +21,8 @@ describe('SettingsPanel', () => { const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps)); expect(html).toContain('settings-panel'); + expect(html).toContain('settings-hero'); + expect(html).toContain('settings-nav'); expect(html).toContain(t.settings.nicknameLabel); expect(html).toContain('value="Night Owl"'); expect(html).toContain(t.settings.languageLabel); @@ -35,6 +37,8 @@ describe('SettingsPanel', () => { expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); expect(html).toContain('Codex 실험 기능'); + expect(html).toContain('href="#settings-codex"'); + expect(html).toContain('/goal'); expect(html).toContain('변경 적용'); expect(html).toContain('불러오는 중'); expect(html).toContain('Claude'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 2028d3e..77b21fc 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -31,6 +31,34 @@ interface AccountData { codexCurrentIndex?: number; } +const SETTINGS_NAV_ITEMS = [ + { + href: '#settings-general', + title: '일반', + detail: '표시 · 언어', + }, + { + href: '#settings-models', + title: '모델', + detail: 'owner · reviewer · arbiter', + }, + { + href: '#settings-moa', + title: 'MoA', + detail: '참조 모델 · 연결 테스트', + }, + { + href: '#settings-codex', + title: 'Codex', + detail: 'fast mode · /goal', + }, + { + href: '#settings-accounts', + title: '계정', + detail: 'Claude · Codex', + }, +] as const; + export interface SettingsPanelProps { locale: Locale; nickname: string; @@ -50,54 +78,134 @@ export function SettingsPanel({ }: SettingsPanelProps) { return (

-
-

일반

- - -
+ +
+ +
+
+ +
+ + +
+
- + - + - + - +
+ +
+ + +
+
- - - + +
+
); } +function SettingsHero() { + return ( +
+
+ Operations console +

설정

+

+ 모델, MoA, Codex 실험 기능, 계정을 한 화면에서 조정합니다. 런타임에 + 반영되는 항목은 저장 후 한 번만 재시작하면 됩니다. +

+
+
+ Codex /goal + MoA + Accounts +
+
+ ); +} + +function SettingsNav() { + return ( + + ); +} + +function SettingsSectionHeading({ + detail, + title, + description, +}: { + detail: string; + title: string; + description: string; +}) { + return ( +
+ {detail} +

{title}

+

{description}

+
+ ); +} + function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) { return (
+ Apply changes 변경 적용 모델, MoA, Codex 실험 기능, 계정 변경은 저장 후 스택 재시작으로 @@ -188,8 +296,12 @@ function ModelSettings() { JSON.stringify(draft) !== JSON.stringify(config); return ( -
-

모델

+
+ {error ?

{error}

: null} {!draft ? (

@@ -228,7 +340,7 @@ function ModelSettings() { {savedAt && !dirty ? ( - 저장됨. 적용하려면 스택 재시작 필요. + 저장됨. 적용하려면 상단의 스택 재시작을 눌러 주세요. ) : null}

@@ -279,8 +391,8 @@ function FastModeSettings() { } return ( -
-

패스트 모드

+
+

패스트 모드

{error ?

{error}

: null} {!state ? (

불러오는 중…

@@ -365,8 +477,8 @@ function CodexFeatureSettings() { } return ( -
-

Codex 실험 기능

+
+

Codex 실험 기능

{error ?

{error}

: null} {!state ? (

불러오는 중…

@@ -534,8 +646,12 @@ function AccountSettings() { } return ( -
-

계정

+
+ {error ?

{error}

: null} div { + position: relative; + z-index: 1; +} + +.settings-hero h2 { + margin: 4px 0 6px; + font-size: clamp(26px, 4vw, 42px); + line-height: 0.95; + letter-spacing: -0.05em; +} + +.settings-hero p { + max-width: 62ch; + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.7; +} + +.settings-kicker { + color: var(--accent-strong); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.settings-hero-badges { + display: flex; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; + min-width: 220px; +} + +.settings-hero-badges span { + padding: 6px 9px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.045); + color: var(--bg-ink); + font-size: 11px; + font-weight: 700; +} + +.settings-layout { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 16px; + align-items: start; +} + +.settings-nav { + position: sticky; + top: 72px; + display: grid; + gap: 8px; + padding: 10px; + border: 1px solid var(--panel-border); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent), + rgba(19, 24, 22, 0.82); + box-shadow: var(--card-shadow); +} + +.settings-nav a { + display: grid; + gap: 2px; + padding: 10px 12px; + border: 1px solid transparent; + border-radius: 12px; + color: var(--muted); + text-decoration: none; + transition: + background 140ms ease, + border-color 140ms ease, + color 140ms ease, + transform 140ms ease; +} + +.settings-nav a:hover { + transform: translateX(2px); + border-color: rgba(214, 130, 88, 0.24); + background: rgba(214, 130, 88, 0.08); + color: var(--bg-ink); +} + +.settings-nav strong { + color: currentColor; + font-size: 13px; +} + +.settings-nav small { + color: var(--muted-soft); + font-size: 11px; +} + +.settings-content { + display: grid; + gap: 14px; + min-width: 0; +} + .settings-row { display: grid; gap: 6px; @@ -648,10 +787,10 @@ button:disabled { .settings-row input, .settings-row select { - min-height: 36px; + min-height: 38px; padding: 0 12px; border: 1px solid var(--panel-border); - border-radius: 8px; + border-radius: 10px; background: var(--bg-card); color: var(--bg-ink); font: inherit; @@ -668,23 +807,50 @@ button:disabled { .settings-hint { color: var(--muted-soft); font-size: 11px; + line-height: 1.45; } .settings-section { display: grid; - gap: 10px; - margin-bottom: 24px; - padding: 12px; + gap: 14px; + scroll-margin-top: 96px; + padding: 16px; border: 1px solid var(--panel-border); - border-radius: 8px; - background: var(--panel); + border-radius: 16px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 80px), + var(--panel); + box-shadow: var(--card-shadow); } -.settings-section h3 { - margin: 0 0 4px; - font-size: 14px; - font-weight: 700; - letter-spacing: -0.01em; +.settings-section-head { + display: grid; + gap: 4px; + padding-bottom: 10px; + border-bottom: 1px solid var(--divider); +} + +.settings-section-head > span { + color: var(--accent); + font-size: 10px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.settings-section-head h3 { + margin: 0; + font-size: 18px; + font-weight: 800; + letter-spacing: -0.03em; +} + +.settings-section-head p { + max-width: 64ch; + margin: 0; + color: var(--muted); + font-size: 12px; + line-height: 1.55; } .settings-section h4 { @@ -696,6 +862,26 @@ button:disabled { letter-spacing: 0.05em; } +.settings-form-grid { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(180px, 0.8fr); + gap: 12px; +} + +.settings-section-stack { + display: grid; + gap: 10px; +} + +.settings-subsection { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid rgba(255, 255, 255, 0.045); + border-radius: 12px; + background: rgba(255, 255, 255, 0.018); +} + .settings-apply-bar { position: sticky; top: 72px; @@ -704,13 +890,14 @@ button:disabled { gap: 12px; align-items: center; justify-content: space-between; - padding: 12px; + padding: 14px 16px; border: 1px solid rgba(214, 130, 88, 0.32); - border-radius: 10px; + border-radius: 16px; background: - linear-gradient(135deg, rgba(214, 130, 88, 0.14), transparent 55%), - rgba(19, 24, 22, 0.94); - box-shadow: 0 10px 28px rgba(0, 0, 0, 0.2); + linear-gradient(135deg, rgba(214, 130, 88, 0.2), transparent 56%), + rgba(16, 20, 18, 0.96); + box-shadow: 0 14px 36px rgba(0, 0, 0, 0.28); + backdrop-filter: blur(16px); } .settings-apply-bar > div { @@ -721,7 +908,8 @@ button:disabled { .settings-apply-bar strong { color: var(--bg-ink); - font-size: 13px; + font-size: 15px; + letter-spacing: -0.02em; } .settings-apply-bar small { @@ -730,10 +918,14 @@ button:disabled { } .settings-row-inline { - grid-template-columns: 80px minmax(0, 1fr) 100px; + grid-template-columns: 92px minmax(0, 1fr) 112px; display: grid; gap: 8px; align-items: center; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: 12px; + background: rgba(255, 255, 255, 0.015); } .settings-row-inline > .settings-label { @@ -760,10 +952,20 @@ button:disabled { grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; - padding: 10px 12px; + padding: 12px 14px; border: 1px solid var(--panel-border); - border-radius: 8px; + border-radius: 12px; background: rgba(255, 255, 255, 0.02); + transition: + border-color 140ms ease, + background 140ms ease, + transform 140ms ease; +} + +.settings-toggle-row:hover { + transform: translateY(-1px); + border-color: rgba(214, 130, 88, 0.22); + background: rgba(255, 255, 255, 0.035); } .settings-toggle-row + .settings-toggle-row { @@ -783,8 +985,8 @@ button:disabled { } .settings-toggle-row input[type='checkbox'] { - width: 18px; - height: 18px; + width: 20px; + height: 20px; cursor: pointer; accent-color: var(--accent); } @@ -794,7 +996,7 @@ button:disabled { min-height: 32px; padding: 0 14px; border: 0; - border-radius: 6px; + border-radius: 999px; background: var(--accent); color: var(--bg); font-size: 12px; @@ -809,9 +1011,10 @@ button:disabled { } .settings-restart { - background: rgba(224, 100, 75, 0.16); - color: var(--status-critical); - border: 1px solid rgba(224, 100, 75, 0.32); + background: linear-gradient(135deg, #e89970, #d66f4e); + color: #150f0b; + border: 1px solid rgba(255, 210, 180, 0.22); + box-shadow: 0 8px 20px rgba(214, 111, 78, 0.18); } .settings-error { @@ -826,7 +1029,7 @@ button:disabled { .settings-account-group { display: grid; - gap: 8px; + gap: 10px; } .settings-account-list { @@ -834,7 +1037,7 @@ button:disabled { margin: 0; padding: 0; display: grid; - gap: 4px; + gap: 6px; } .settings-account-group-head { @@ -849,9 +1052,9 @@ button:disabled { grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; - padding: 8px 10px; + padding: 10px 12px; border: 1px solid var(--panel-border); - border-radius: 8px; + border-radius: 12px; background: rgba(255, 255, 255, 0.02); } @@ -870,7 +1073,7 @@ button:disabled { min-height: 28px; padding: 0 10px; border: 1px solid var(--panel-border-strong); - border-radius: 6px; + border-radius: 999px; background: rgba(255, 255, 255, 0.04); color: var(--bg-ink); font-size: 11px; @@ -915,7 +1118,7 @@ button:disabled { min-width: 0; padding: 0 10px; border: 1px solid var(--panel-border); - border-radius: 7px; + border-radius: 9px; background: var(--bg-card); color: var(--bg-ink); font: inherit; @@ -1005,7 +1208,7 @@ button:disabled { min-height: 28px; padding: 0 10px; border: 1px solid rgba(224, 100, 75, 0.32); - border-radius: 6px; + border-radius: 999px; background: rgba(224, 100, 75, 0.06); color: var(--status-critical); font-size: 11px; @@ -1028,7 +1231,7 @@ button:disabled { .settings-add-token textarea { width: 100%; border: 1px solid var(--panel-border-strong); - border-radius: 6px; + border-radius: 10px; padding: 8px 10px; background: var(--bg-card); color: var(--bg-ink); @@ -1042,7 +1245,7 @@ button:disabled { min-height: 36px; padding: 0 14px; border: 0; - border-radius: 6px; + border-radius: 999px; background: var(--accent); color: var(--bg); font-size: 12px; @@ -1055,6 +1258,51 @@ button:disabled { cursor: not-allowed; } +@media (max-width: 980px) { + .settings-layout { + grid-template-columns: 1fr; + } + + .settings-nav { + position: static; + grid-template-columns: repeat(5, minmax(120px, 1fr)); + overflow-x: auto; + } +} + +@media (max-width: 720px) { + .settings-hero, + .settings-apply-bar { + align-items: stretch; + flex-direction: column; + } + + .settings-hero-badges { + justify-content: flex-start; + min-width: 0; + } + + .settings-form-grid, + .settings-row-inline, + .settings-account-row, + .settings-add-token { + grid-template-columns: 1fr; + } + + .settings-moa-grid { + grid-template-columns: 1fr; + } + + .settings-account-main { + grid-template-columns: 28px minmax(0, 1fr); + } + + .settings-account-actions { + justify-content: flex-start; + flex-wrap: wrap; + } +} + .room-inbox-badges { display: inline-flex; gap: 4px; From 6614deee56d9c1b190e90c108637f991d556b608 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 03:04:25 +0900 Subject: [PATCH 07/39] fix settings nav hash routing --- apps/dashboard/src/SettingsPanel.test.ts | 4 ++- apps/dashboard/src/SettingsPanel.tsx | 42 +++++++++++------------- apps/dashboard/src/styles.css | 15 +++++++-- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index d9882af..2910103 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -37,7 +37,9 @@ describe('SettingsPanel', () => { expect(html).toContain('MoA 참조 모델'); expect(html).toContain('패스트 모드'); expect(html).toContain('Codex 실험 기능'); - expect(html).toContain('href="#settings-codex"'); + expect(html).toContain('data-settings-target="settings-codex"'); + expect(html).toContain('aria-controls="settings-codex"'); + expect(html).not.toContain('href="#settings-codex"'); expect(html).toContain('/goal'); expect(html).toContain('변경 적용'); expect(html).toContain('불러오는 중'); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 77b21fc..34060c0 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -32,31 +32,15 @@ interface AccountData { } const SETTINGS_NAV_ITEMS = [ + { targetId: 'settings-general', title: '일반', detail: '표시 · 언어' }, { - href: '#settings-general', - title: '일반', - detail: '표시 · 언어', - }, - { - href: '#settings-models', + targetId: 'settings-models', title: '모델', detail: 'owner · reviewer · arbiter', }, - { - href: '#settings-moa', - title: 'MoA', - detail: '참조 모델 · 연결 테스트', - }, - { - href: '#settings-codex', - title: 'Codex', - detail: 'fast mode · /goal', - }, - { - href: '#settings-accounts', - title: '계정', - detail: 'Claude · Codex', - }, + { targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' }, + { targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' }, + { targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' }, ] as const; export interface SettingsPanelProps { @@ -171,13 +155,25 @@ function SettingsHero() { } function SettingsNav() { + const scrollToSection = (targetId: string) => { + document + .getElementById(targetId) + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + return ( ); diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index c957cf8..0d5b37c 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -734,14 +734,17 @@ button:disabled { box-shadow: var(--card-shadow); } -.settings-nav a { +.settings-nav button { display: grid; gap: 2px; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; + background: transparent; color: var(--muted); - text-decoration: none; + font: inherit; + text-align: left; + cursor: pointer; transition: background 140ms ease, border-color 140ms ease, @@ -749,13 +752,19 @@ button:disabled { transform 140ms ease; } -.settings-nav a:hover { +.settings-nav button:hover, +.settings-nav button:focus-visible { transform: translateX(2px); border-color: rgba(214, 130, 88, 0.24); background: rgba(214, 130, 88, 0.08); color: var(--bg-ink); } +.settings-nav button:focus-visible { + outline: 2px solid rgba(214, 130, 88, 0.34); + outline-offset: 2px; +} + .settings-nav strong { color: currentColor; font-size: 13px; From 443154d2e57b2dee6582753aaad9891beafc2972 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 03:47:08 +0900 Subject: [PATCH 08/39] add dashboard UX verification --- apps/dashboard/src/SettingsPanel.tsx | 10 +- apps/dashboard/src/styles.css | 2 +- bun.lock | 5 + package.json | 2 + scripts/dashboard-ux.ts | 363 +++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 10 deletions(-) create mode 100644 scripts/dashboard-ux.ts diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index 34060c0..6d56ef2 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -210,15 +210,7 @@ function SettingsApplyBar({ onRestartStack }: { onRestartStack: () => void }) { - ) : ( - - {!secureContext - ? t.pwa.secureRequired - : offlineReady - ? t.pwa.cached - : t.pwa.online} - - )} - -
- {t.metrics.rooms} - - {data.overview.rooms.active + data.overview.rooms.waiting}/ - {data.overview.rooms.total} - - {t.control.activeRooms} -
-
- {t.control.queue} - {queue.pendingTasks} - - {queue.pendingMessageRooms} {t.control.pendingRooms} - -
-
- {t.metrics.agents} - {data.overview.services.length} - {t.panels.heartbeat} -
-
- {t.metrics.ciWatchers} - {data.overview.tasks.watchers.active} - - {data.overview.tasks.watchers.paused} {t.status.paused} - -
-
- ); -} - function DashboardErrorCard({ error, onRetry, @@ -388,8 +287,7 @@ function App() { const [taskActionKey, setTaskActionKey] = useState( null, ); - const [serviceActionKey, setServiceActionKey] = - useState(null); + const [serviceRestarting, setServiceRestarting] = useState(false); const [roomMessageKey, setRoomMessageKey] = useState(null); const [selectedRoomJid, setSelectedRoomJid] = useState(null); const { @@ -498,7 +396,7 @@ function App() { return; } - setServiceActionKey('stack:restart'); + setServiceRestarting(true); try { await runServiceAction('stack', 'restart', { requestId: makeClientRequestId(), @@ -507,7 +405,7 @@ function App() { } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { - setServiceActionKey(null); + setServiceRestarting(false); } } @@ -793,43 +691,12 @@ function App() { {data ? (
{activeView === 'usage' ? ( - <> -
-
-

{t.panels.usage}

- {t.panels.usageWindow} -
- -
- void handleInstallApp()} - secureContext={secureContext} - t={t} - /> - - ) : null} - - {activeView === 'health' ? ( -
+
-

{t.panels.health}

- {t.panels.healthSignals} +

{t.panels.usage}

+ {t.panels.usageWindow}
- void handleServiceRestart()} - overview={data.overview} - serviceActionKey={serviceActionKey} - snapshots={data.snapshots} - t={t} - /> +
) : null} @@ -839,6 +706,7 @@ function App() {

{t.panels.rooms}

{t.panels.queue}
+ void handleServiceRestart()} + onRestartStack={() => { + if (!serviceRestarting) void handleServiceRestart(); + }} t={t} />
diff --git a/apps/dashboard/src/DashboardNav.tsx b/apps/dashboard/src/DashboardNav.tsx index 2ab40b6..f0d98cf 100644 --- a/apps/dashboard/src/DashboardNav.tsx +++ b/apps/dashboard/src/DashboardNav.tsx @@ -1,6 +1,5 @@ import { type ReactNode } from 'react'; import { - Activity, Clock, Download, Gauge, @@ -12,12 +11,7 @@ import { import { type DashboardOverview, type StatusSnapshot } from './api'; import { type Messages } from './i18n'; -export type DashboardView = - | 'usage' - | 'health' - | 'rooms' - | 'scheduled' - | 'settings'; +export type DashboardView = 'usage' | 'rooms' | 'scheduled' | 'settings'; export type DashboardFreshness = 'fresh' | 'stale' | 'offline'; interface DashboardNavData { @@ -27,7 +21,6 @@ interface DashboardNavData { const NAV_ICONS: Record = { usage: , - health: , rooms: , scheduled: , settings: , @@ -37,7 +30,6 @@ function navItems(t: Messages) { return [ { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const }, - { href: '#/health', label: t.nav.health, view: 'health' as const }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const }, { href: '#/settings', label: t.nav.settings, view: 'settings' as const }, ]; diff --git a/apps/dashboard/src/ServicePanel.test.ts b/apps/dashboard/src/ServicePanel.test.ts deleted file mode 100644 index 416fb8a..0000000 --- a/apps/dashboard/src/ServicePanel.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createElement } from 'react'; -import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; - -import type { DashboardOverview, StatusSnapshot } from './api'; -import { messages, type Messages } from './i18n'; -import { ServicePanel, type ServicePanelProps } from './ServicePanel'; - -const t = messages.en; - -function formatDuration(value: number | null, t: Messages): string { - if (value === null) return '-'; - const seconds = Math.floor(value / 1000); - if (seconds < 60) return `${seconds}${t.units.second}`; - const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}${t.units.minute}`; - const hours = Math.floor(minutes / 60); - return `${hours}${t.units.hour} ${minutes % 60}${t.units.minute}`; -} - -function overview( - services: DashboardOverview['services'], - overrides: Partial = {}, -): DashboardOverview { - return { - generatedAt: '2026-04-28T04:15:00.000Z', - inbox: [], - operations: { serviceRestarts: [] }, - rooms: { active: 0, inactive: 0, total: 0, waiting: 0 }, - services, - tasks: { - active: 0, - completed: 0, - paused: 0, - total: 0, - watchers: { active: 0, completed: 0, paused: 0 }, - }, - usage: { fetchedAt: '2026-04-28T04:15:00.000Z', rows: [] }, - ...overrides, - }; -} - -const snapshots: StatusSnapshot[] = [ - { - agentType: 'codex', - assistantName: 'owner', - entries: [ - { - agentType: 'codex', - elapsedMs: null, - folder: 'eyejokerdb', - jid: 'room-1', - name: 'eyejokerdb-main', - pendingMessages: true, - pendingTasks: 3, - status: 'waiting', - }, - ], - serviceId: 'svc-owner', - updatedAt: '2026-04-28T04:14:00.000Z', - }, -]; - -const baseProps: ServicePanelProps = { - formatDuration, - locale: 'en', - onRestartStack: () => {}, - overview: overview( - [ - { - activeRooms: 2, - agentType: 'codex', - assistantName: 'owner', - serviceId: 'svc-owner', - totalRooms: 3, - updatedAt: '2026-04-28T04:14:00.000Z', - }, - { - activeRooms: 0, - agentType: 'claude', - assistantName: 'reviewer', - serviceId: 'svc-reviewer', - totalRooms: 1, - updatedAt: '2026-04-28T04:08:00.000Z', - }, - ], - { - operations: { - serviceRestarts: [ - { - completedAt: null, - id: 'restart-1', - requestedAt: '2026-04-28T04:13:00.000Z', - services: ['owner', 'reviewer'], - status: 'running', - target: 'stack', - }, - ], - }, - tasks: { - active: 0, - completed: 0, - paused: 2, - total: 2, - watchers: { active: 0, completed: 0, paused: 2 }, - }, - }, - ), - serviceActionKey: null, - snapshots, - t, -}; - -describe('ServicePanel', () => { - it('renders service health, queue signals, and restart log', () => { - const html = renderToStaticMarkup(createElement(ServicePanel, baseProps)); - - expect(html).toContain(t.health.levels.stale); - expect(html).toContain(t.health.ciFailures); - expect(html).toContain('2'); - expect(html).toContain('3'); - expect(html).toContain('reviewer'); - expect(html).toContain(t.health.restartLog); - expect(html).toContain('owner, reviewer'); - }); - - it('renders restart pending state and empty services state', () => { - const html = renderToStaticMarkup( - createElement(ServicePanel, { - ...baseProps, - overview: overview([]), - serviceActionKey: 'stack:restart', - snapshots: [], - }), - ); - - expect(html).toContain(t.health.restarting); - expect(html).toContain('disabled=""'); - expect(html).toContain(t.service.empty); - }); -}); diff --git a/apps/dashboard/src/ServicePanel.tsx b/apps/dashboard/src/ServicePanel.tsx deleted file mode 100644 index e87ab5a..0000000 --- a/apps/dashboard/src/ServicePanel.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import type { DashboardOverview, StatusSnapshot } from './api'; -import { formatDate } from './dashboardHelpers'; -import { EmptyState } from './EmptyState'; -import type { Locale, Messages } from './i18n'; - -export type ServiceActionKey = 'stack:restart'; - -type HealthLevel = 'ok' | 'stale' | 'down'; -type ServiceRow = DashboardOverview['services'][number]; - -export interface ServicePanelProps { - formatDuration: (value: number | null, t: Messages) => string; - locale: Locale; - onRestartStack: () => void; - overview: DashboardOverview; - serviceActionKey: ServiceActionKey | null; - snapshots: StatusSnapshot[]; - t: Messages; -} - -const HEALTH_STALE_MS = 5 * 60_000; -const HEALTH_DOWN_MS = 15 * 60_000; - -function serviceAgeMs(service: ServiceRow, generatedAt: string): number | null { - const updated = new Date(service.updatedAt).getTime(); - const now = new Date(generatedAt).getTime(); - if (Number.isNaN(updated) || Number.isNaN(now)) return null; - return Math.max(0, now - updated); -} - -function serviceHealthLevel( - service: ServiceRow, - generatedAt: string, -): HealthLevel { - const age = serviceAgeMs(service, generatedAt); - if (age === null) return 'stale'; - if (age >= HEALTH_DOWN_MS) return 'down'; - if (age >= HEALTH_STALE_MS) return 'stale'; - return 'ok'; -} - -export function ServicePanel({ - formatDuration, - locale, - onRestartStack, - overview, - serviceActionKey, - snapshots, - t, -}: ServicePanelProps) { - const services = overview.services; - const restarts = overview.operations?.serviceRestarts ?? []; - const serviceLevels = services.map((service) => ({ - service, - level: serviceHealthLevel(service, overview.generatedAt), - age: serviceAgeMs(service, overview.generatedAt), - })); - const down = serviceLevels.filter((item) => item.level === 'down').length; - const stale = serviceLevels.filter((item) => item.level === 'stale').length; - const queue = snapshots.reduce( - (acc, snapshot) => { - for (const entry of snapshot.entries) { - acc.pendingTasks += entry.pendingTasks; - if (entry.pendingMessages) acc.pendingMessageRooms += 1; - } - return acc; - }, - { pendingTasks: 0, pendingMessageRooms: 0 }, - ); - const ciFailures = overview.tasks.watchers.paused; - const healthLevel: HealthLevel = - down > 0 ? 'down' : stale > 0 || ciFailures > 0 ? 'stale' : 'ok'; - const affectedServices = serviceLevels.filter((item) => item.level !== 'ok'); - - return ( -
-
- {t.health.system} - {t.health.levels[healthLevel]} -
- -
-
- {t.health.services} - - {services.length - stale - down}/{services.length} - - {t.health.fresh} -
-
- {t.health.stale} - {stale + down} - - {down} {t.health.levels.down} - -
-
- {t.health.queue} - {queue.pendingTasks} - - {queue.pendingMessageRooms} {t.control.pendingRooms} - -
-
- {t.health.ciFailures} - {ciFailures} -
-
- -
-
- {t.health.restart} - {t.health.restartStack} - {t.health.restartHint} -
- -
- - {restarts.length > 0 ? ( -
- - {t.health.restartLog} - {restarts.length} - -
- {restarts.map((restart) => { - const pill = - restart.status === 'success' - ? 'ok' - : restart.status === 'failed' - ? 'error' - : 'stale'; - return ( -
-
- {t.health.restartTarget} - {restart.target} -
- - {restart.status} - -
- {t.health.restartRequested} - {formatDate(restart.requestedAt, locale)} -
-
- {t.health.restartServices} - - {restart.services.length > 0 - ? restart.services.join(', ') - : '-'} - -
- {restart.error ? ( -

{restart.error}

- ) : null} -
- ); - })} -
-
- ) : null} - - {services.length === 0 ? ( - {t.service.empty} - ) : affectedServices.length === 0 ? null : ( -
- - {t.health.affectedServices} - {affectedServices.length} - -
- {affectedServices.map(({ service, level, age }) => ( -
-
- {service.assistantName || service.serviceId} -
- - {t.health.levels[level]} - -
- {t.service.updated} - {formatDate(service.updatedAt, locale)} - {formatDuration(age, t)} -
-
- {t.service.rooms} - - {service.activeRooms}/{service.totalRooms} - -
-
- ))} -
-
- )} -
- ); -} diff --git a/apps/dashboard/src/SystemStatusStrip.test.ts b/apps/dashboard/src/SystemStatusStrip.test.ts new file mode 100644 index 0000000..7a48555 --- /dev/null +++ b/apps/dashboard/src/SystemStatusStrip.test.ts @@ -0,0 +1,99 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import type { DashboardOverview } from './api'; +import { messages } from './i18n'; +import { SystemStatusStrip } from './SystemStatusStrip'; + +const t = messages.en; + +function overview( + overrides: Partial = {}, +): DashboardOverview { + return { + generatedAt: '2026-04-28T04:15:00.000Z', + inbox: [], + operations: { serviceRestarts: [] }, + rooms: { active: 0, inactive: 0, total: 0, waiting: 0 }, + services: [ + { + activeRooms: 2, + agentType: 'codex', + assistantName: 'owner', + serviceId: 'svc-owner', + totalRooms: 3, + updatedAt: '2026-04-28T04:14:00.000Z', + }, + ], + tasks: { + active: 0, + completed: 0, + paused: 0, + total: 0, + watchers: { active: 0, completed: 0, paused: 0 }, + }, + usage: { fetchedAt: '2026-04-28T04:15:00.000Z', rows: [] }, + ...overrides, + }; +} + +describe('SystemStatusStrip', () => { + it('stays hidden when services and CI watchers are healthy', () => { + const html = renderToStaticMarkup( + createElement(SystemStatusStrip, { overview: overview(), t }), + ); + + expect(html).toBe(''); + }); + + it('surfaces paused CI watchers as a room-level system warning', () => { + const html = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ + tasks: { + active: 0, + completed: 0, + paused: 2, + total: 2, + watchers: { active: 0, completed: 0, paused: 2 }, + }, + }), + t, + }), + ); + + expect(html).toContain('system-status-strip'); + expect(html).toContain(t.health.ciFailures); + expect(html).toContain('2'); + }); + + it('surfaces stale and missing service heartbeat signals', () => { + const staleHtml = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ + services: [ + { + activeRooms: 0, + agentType: 'claude', + assistantName: 'reviewer', + serviceId: 'svc-reviewer', + totalRooms: 1, + updatedAt: '2026-04-28T03:58:00.000Z', + }, + ], + }), + t, + }), + ); + const missingHtml = renderToStaticMarkup( + createElement(SystemStatusStrip, { + overview: overview({ services: [] }), + t, + }), + ); + + expect(staleHtml).toContain(t.health.levels.down); + expect(missingHtml).toContain(t.service.empty); + }); +}); diff --git a/apps/dashboard/src/SystemStatusStrip.tsx b/apps/dashboard/src/SystemStatusStrip.tsx new file mode 100644 index 0000000..408b763 --- /dev/null +++ b/apps/dashboard/src/SystemStatusStrip.tsx @@ -0,0 +1,66 @@ +import type { DashboardOverview } from './api'; +import type { Messages } from './i18n'; + +type HealthLevel = 'stale' | 'down'; +type ServiceRow = DashboardOverview['services'][number]; + +export interface SystemStatusStripProps { + overview: DashboardOverview; + t: Messages; +} + +const HEALTH_STALE_MS = 5 * 60_000; +const HEALTH_DOWN_MS = 15 * 60_000; + +function serviceAgeMs(service: ServiceRow, generatedAt: string): number | null { + const updated = new Date(service.updatedAt).getTime(); + const now = new Date(generatedAt).getTime(); + if (Number.isNaN(updated) || Number.isNaN(now)) return null; + return Math.max(0, now - updated); +} + +function serviceHealthLevel( + service: ServiceRow, + generatedAt: string, +): HealthLevel | null { + const age = serviceAgeMs(service, generatedAt); + if (age === null) return 'stale'; + if (age >= HEALTH_DOWN_MS) return 'down'; + if (age >= HEALTH_STALE_MS) return 'stale'; + return null; +} + +export function SystemStatusStrip({ overview, t }: SystemStatusStripProps) { + const serviceLevels = overview.services.map((service) => + serviceHealthLevel(service, overview.generatedAt), + ); + const down = serviceLevels.filter((level) => level === 'down').length; + const stale = serviceLevels.filter((level) => level === 'stale').length; + const ciPaused = overview.tasks.watchers.paused; + const hasNoHeartbeat = overview.services.length === 0; + const level: HealthLevel = hasNoHeartbeat || down > 0 ? 'down' : 'stale'; + + if (!hasNoHeartbeat && down === 0 && stale === 0 && ciPaused === 0) { + return null; + } + + const signals: string[] = []; + if (hasNoHeartbeat) signals.push(t.service.empty); + if (down > 0) signals.push(`${down} ${t.health.levels.down}`); + if (stale > 0) signals.push(`${stale} ${t.health.stale}`); + if (ciPaused > 0) signals.push(`${ciPaused} ${t.health.ciFailures}`); + + return ( + + ); +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 7a125d7..00b2069 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -1576,6 +1576,54 @@ dd, padding: 12px; } +.system-status-strip { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: center; + padding: 12px 14px; + border: 1px solid rgba(181, 138, 34, 0.32); + border-radius: 16px; + background: + linear-gradient( + 135deg, + rgba(255, 241, 213, 0.86), + rgba(255, 250, 240, 0.94) + ), + var(--bg-card); +} + +.system-status-strip.system-status-down { + border-color: rgba(183, 71, 52, 0.36); + background: + linear-gradient( + 135deg, + rgba(255, 230, 220, 0.88), + rgba(255, 250, 240, 0.96) + ), + var(--bg-card); +} + +.system-status-strip > div { + display: grid; + gap: 2px; + min-width: 0; +} + +.system-status-strip strong { + color: var(--bg-ink); + font-size: var(--text-lg); + line-height: 1; +} + +.system-status-strip p { + margin: 0; + color: var(--muted); + font-size: var(--text-sm); + font-weight: 700; + overflow-wrap: anywhere; +} + .panel-title { display: flex; gap: 10px; @@ -2875,6 +2923,10 @@ progress::-moz-progress-bar { .ops-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .system-status-strip { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index ef8ba40..6064971 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -128,8 +128,9 @@ async function main() { await page.waitForURL(/#\/rooms$/); await assertVisible(page.locator('#rooms .rooms-v2')); assert.equal(await page.locator('a[href="#/inbox"]').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); await assertVisible(page.getByText(/승인|Approval/).first()); - assert.equal(await page.getByText(/CI 실패|CI failure/).count(), 0); + await assertVisible(page.locator('.system-status-strip')); assert.equal( await page.getByRole('button', { name: 'Dismiss' }).count(), 0, @@ -141,6 +142,33 @@ async function main() { }, ); + await runScenario( + 'health route redirects to rooms and degraded state is conditional', + browser, + baseUrl, + async (page, state) => { + state.approvalAction = true; + + await page.goto(new URL('/#/rooms', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + await assertVisible(page.locator('#rooms .rooms-v2')); + assert.equal(await page.locator('.system-status-strip').count(), 0); + + state.ciWatcherFailures = 2; + await page.goto(new URL('/?degraded=1#/health', baseUrl).toString(), { + waitUntil: 'networkidle', + }); + + await page.waitForURL(/#\/rooms$/); + await assertVisible(page.locator('#rooms .rooms-v2')); + await assertVisible(page.locator('.system-status-strip')); + assert.equal(await page.locator('#health').count(), 0); + assert.equal(await page.locator('a[href="#/health"]').count(), 0); + await assertVisible(page.getByText(/CI 실패|CI failure/).first()); + }, + ); + console.log('dashboard:ux passed'); } finally { await browser.close(); From 6356b7683662c5c93debea59d5bd2a280bcfa1ae Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 22:00:07 +0900 Subject: [PATCH 12/39] fix: allow runtime image attachment paths --- src/attachment-base-dirs.ts | 21 +++++++++++++ src/ipc-outbound-delivery.test.ts | 10 ++++-- src/ipc-outbound-delivery.ts | 3 +- src/message-runtime-final-delivery.test.ts | 9 ++++-- src/message-runtime-final-delivery.ts | 3 +- src/message-runtime.ts | 3 +- src/outbound-attachments.test.ts | 21 +++++++++++++ src/outbound-attachments.ts | 36 ++-------------------- 8 files changed, 66 insertions(+), 40 deletions(-) create mode 100644 src/attachment-base-dirs.ts diff --git a/src/attachment-base-dirs.ts b/src/attachment-base-dirs.ts new file mode 100644 index 0000000..8f49b98 --- /dev/null +++ b/src/attachment-base-dirs.ts @@ -0,0 +1,21 @@ +import path from 'path'; + +import { DATA_DIR } from './config.js'; +import { isValidGroupFolder } from './group-folder.js'; +import type { RegisteredGroup } from './types.js'; + +function unique(values: Array): string[] { + return [ + ...new Set(values.filter((value): value is string => Boolean(value))), + ]; +} + +export function resolveRuntimeAttachmentBaseDirs( + group: RegisteredGroup, +): string[] | undefined { + const workspaceRoot = isValidGroupFolder(group.folder) + ? path.resolve(DATA_DIR, 'workspaces', group.folder) + : null; + const dirs = unique([group.workDir, workspaceRoot]); + return dirs.length > 0 ? dirs : undefined; +} diff --git a/src/ipc-outbound-delivery.test.ts b/src/ipc-outbound-delivery.test.ts index baad6bd..aa20f3d 100644 --- a/src/ipc-outbound-delivery.test.ts +++ b/src/ipc-outbound-delivery.test.ts @@ -94,7 +94,10 @@ describe('deliverIpcOutboundMessage', () => { expect.objectContaining({ channel, item: createdItem, - attachmentBaseDirs: ['/repo'], + attachmentBaseDirs: expect.arrayContaining([ + '/repo', + expect.stringMatching(/data\/workspaces\/room-folder$/), + ]), }), ); expect(channel.sendMessage).not.toHaveBeenCalled(); @@ -144,7 +147,10 @@ describe('deliverIpcOutboundMessage', () => { expect.objectContaining({ channel: reviewerChannel, item: createdItem, - attachmentBaseDirs: ['/repo'], + attachmentBaseDirs: expect.arrayContaining([ + '/repo', + expect.stringMatching(/data\/workspaces\/room-folder$/), + ]), }), ); expect(noteDirectTerminalDelivery).toHaveBeenCalledWith( diff --git a/src/ipc-outbound-delivery.ts b/src/ipc-outbound-delivery.ts index 328d8bc..abb75fe 100644 --- a/src/ipc-outbound-delivery.ts +++ b/src/ipc-outbound-delivery.ts @@ -1,5 +1,6 @@ import { createProducedWorkItem } from './db.js'; import type { WorkItem } from './db/work-items.js'; +import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js'; import { deliverOpenWorkItem } from './message-runtime-delivery.js'; import { parseVisibleVerdict } from './paired-verdict.js'; import { resolveChannelForDeliveryRole } from './router.js'; @@ -129,7 +130,7 @@ export async function deliverCanonicalOutboundMessage( channel: route.channel, item: workItem as WorkItem, log, - attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, + attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(group), isDuplicateOfLastBotFinal: () => false, openContinuation: () => {}, }); diff --git a/src/message-runtime-final-delivery.test.ts b/src/message-runtime-final-delivery.test.ts index e12718a..0934f16 100644 --- a/src/message-runtime-final-delivery.test.ts +++ b/src/message-runtime-final-delivery.test.ts @@ -149,7 +149,10 @@ describe('message-runtime-final-delivery', () => { expect.objectContaining({ channel, log: logger, - attachmentBaseDirs: ['/work/room'], + attachmentBaseDirs: expect.arrayContaining([ + '/work/room', + expect.stringMatching(/data\/workspaces\/room-folder$/), + ]), replaceMessageId: undefined, isDuplicateOfLastBotFinal, openContinuation, @@ -189,7 +192,9 @@ describe('message-runtime-final-delivery', () => { expect(deliverOpenWorkItem).toHaveBeenCalledWith( expect.objectContaining({ replaceMessageId: 'discord-message-1', - attachmentBaseDirs: undefined, + attachmentBaseDirs: expect.arrayContaining([ + expect.stringMatching(/data\/workspaces\/room-folder$/), + ]), }), ); }); diff --git a/src/message-runtime-final-delivery.ts b/src/message-runtime-final-delivery.ts index 977044d..f5c15bb 100644 --- a/src/message-runtime-final-delivery.ts +++ b/src/message-runtime-final-delivery.ts @@ -1,4 +1,5 @@ import { createProducedWorkItem } from './db.js'; +import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js'; import { logger } from './logger.js'; import { deliverOpenWorkItem } from './message-runtime-delivery.js'; import type { @@ -65,7 +66,7 @@ export async function deliverMessageRuntimeFinalText(args: { channel: args.channel, item: workItem, log: logger, - attachmentBaseDirs: args.group.workDir ? [args.group.workDir] : undefined, + attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(args.group), replaceMessageId: args.replaceMessageId, isDuplicateOfLastBotFinal: args.isDuplicateOfLastBotFinal, openContinuation: args.openContinuation, diff --git a/src/message-runtime.ts b/src/message-runtime.ts index edcb830..9245310 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -9,6 +9,7 @@ import { isSessionCommandSenderAllowed, SERVICE_SESSION_SCOPE, } from './config.js'; +import { resolveRuntimeAttachmentBaseDirs } from './attachment-base-dirs.js'; import { GroupQueue, GroupRunContext } from './group-queue.js'; import { findChannel, formatMessages } from './router.js'; import { enqueueGenericFollowUpAfterDeliveryRetry as enqueueDeliveryRetryFollowUp } from './message-runtime-dispatch.js'; @@ -311,7 +312,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel, roleToChannel, log, - attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, + attachmentBaseDirs: resolveRuntimeAttachmentBaseDirs(group), isPairedRoom: hasReviewerLease(chatJid), getMissingRoleChannelMessage, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index a7f97fe..6911bb9 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -111,6 +111,27 @@ describe('validateOutboundAttachments', () => { ]); }); + it('accepts generated image files in nested temp directories', () => { + const dir = makeTempDir(os.tmpdir(), 'paladin-character-'); + const imagePath = writeFile(dir, 'sheet_12x.png', ONE_PIXEL_PNG); + + const result = validateOutboundAttachments([ + { + path: imagePath, + name: 'sheet_12x.png', + mime: 'image/png', + }, + ]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'sheet_12x.png', + }, + ]); + }); + it('requires workspace paths to be explicitly allowlisted', () => { const dir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const imagePath = writeFile(dir, 'workspace-shot.png', ONE_PIXEL_PNG); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts index 96a05ab..2787f67 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -17,11 +17,6 @@ export interface ValidateOutboundAttachmentsResult { const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; const IMAGE_EXTS = /\.(png|jpe?g|gif|webp|bmp)$/i; -const DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES = [ - 'ejclaw-attachment-', - 'ejclaw-discord-image-', -] as const; - function unique(values: Array): string[] { return [ ...new Set(values.filter((value): value is string => Boolean(value))), @@ -65,37 +60,13 @@ function matchesAllowedBaseDir(realPath: string, baseDirs: string[]): boolean { return baseDirs.some((baseDir) => isWithinBaseDir(realPath, baseDir)); } -function isWithinDefaultTempAttachmentDir( - realPath: string, - tempDir: string | null, -): boolean { +function isWithinTempDir(realPath: string, tempDir: string | null): boolean { if (!tempDir) return false; const relative = path.relative(tempDir, realPath); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { return false; } - const [firstSegment, ...rest] = relative.split(path.sep); - return ( - rest.length > 0 && - DEFAULT_TEMP_ATTACHMENT_DIR_PREFIXES.some((prefix) => - firstSegment.startsWith(prefix), - ) - ); -} - -function isDirectTempAttachmentFile( - realPath: string, - tempDir: string | null, -): boolean { - if (!tempDir) return false; - const relative = path.relative(tempDir, realPath); - if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { - return false; - } - // Agents commonly write Playwright screenshots and generated images directly - // under /tmp with task-specific names. Keep this limited to direct children; - // nested temp directories still need an EJClaw prefix or explicit baseDir. - return !relative.includes(path.sep); + return true; } function detectImageMime(filePath: string): string | null { @@ -186,8 +157,7 @@ export function validateOutboundAttachments( } if ( !matchesAllowedBaseDir(realPath, baseDirs) && - !isWithinDefaultTempAttachmentDir(realPath, defaultTempDir) && - !isDirectTempAttachmentFile(realPath, defaultTempDir) + !isWithinTempDir(realPath, defaultTempDir) ) { rejected.push({ path: requestedPath, reason: 'outside-allowed-dirs' }); continue; From 16d460eb85998292dd8ebe732ca3ba7edb788cc4 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sat, 2 May 2026 22:37:55 +0900 Subject: [PATCH 13/39] feat: configure attachment allowlist dirs --- .env.example | 5 ++++ docs/configuration.md | 11 ++++++++ src/outbound-attachments.test.ts | 43 +++++++++++++++++++++++++++++++- src/outbound-attachments.ts | 25 +++++++++++++++++-- 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index bf43e02..199b292 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,11 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # STATUS_SHOW_ROOMS=true # Show room status in dashboard # STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info +# --- Discord image attachments --- +# Extra local directories that agents may attach from. Keep this narrow: +# prefer screenshot/output folders over /home. Comma or path-delimiter separated. +# EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images + # --- Codex warm-up (optional, disabled by default) --- # Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so # reset countdown starts once after a fresh quota window. Keep disabled if diff --git a/docs/configuration.md b/docs/configuration.md index 3547316..aaef8e3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,6 +118,17 @@ SESSION_COMMAND_ALLOWED_SENDERS= MAX_CONCURRENT_AGENTS=5 ``` +## Discord 이미지 첨부 allowlist + +```bash +EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images +``` + +- 콤마 또는 플랫폼 path delimiter(Linux는 `:`)로 여러 폴더를 지정합니다. +- 지정한 폴더 하위의 PNG/JPEG/GIF/WebP/BMP만 Discord 첨부 후보가 됩니다. +- `realpath`, 이미지 signature, size cap 검증은 그대로 적용됩니다. +- `/home/**` 전체보다 자주 쓰는 스크린샷/이미지 출력 폴더만 추가하는 것을 권장합니다. + - `ASSISTANT_NAME`은 owner trigger 기본 이름을 만듭니다 - paired room에서도 사용자 진입점은 owner가 기준입니다 - status dashboard와 session command는 선택 설정입니다 diff --git a/src/outbound-attachments.test.ts b/src/outbound-attachments.test.ts index 6911bb9..42b427a 100644 --- a/src/outbound-attachments.test.ts +++ b/src/outbound-attachments.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { validateOutboundAttachments } from './outbound-attachments.js'; @@ -31,6 +31,7 @@ function writeFile( } afterEach(() => { + vi.unstubAllEnvs(); for (const dir of cleanupDirs.splice(0)) { fs.rmSync(dir, { force: true, recursive: true }); } @@ -154,6 +155,30 @@ describe('validateOutboundAttachments', () => { ]); }); + it('accepts user-configured attachment directories from env', () => { + const unusedCommaDir = makeTempDir(process.cwd(), '.ejclaw-unused-images-'); + const unusedDelimiterDir = makeTempDir( + process.cwd(), + '.ejclaw-more-unused-images-', + ); + const dir = makeTempDir(process.cwd(), '.ejclaw-user-images-'); + const imagePath = writeFile(dir, 'manual-shot.png', ONE_PIXEL_PNG); + vi.stubEnv( + 'EJCLAW_ATTACHMENT_ALLOWED_DIRS', + `${unusedCommaDir},${unusedDelimiterDir}${path.delimiter}${dir}`, + ); + + const result = validateOutboundAttachments([{ path: imagePath }]); + + expect(result.rejected).toEqual([]); + expect(result.files).toEqual([ + { + attachment: fs.realpathSync(imagePath), + name: 'manual-shot.png', + }, + ]); + }); + it('rejects symlink attempts that escape the allowed directory', () => { const workspaceDir = makeTempDir(process.cwd(), '.ejclaw-attachment-'); const targetPath = writeFile( @@ -173,6 +198,22 @@ describe('validateOutboundAttachments', () => { ]); }); + it('rejects symlink attempts that escape a user-configured directory', () => { + const allowedDir = makeTempDir(process.cwd(), '.ejclaw-user-images-'); + const secretDir = makeTempDir(process.cwd(), '.ejclaw-secret-images-'); + const targetPath = writeFile(secretDir, 'secret-shot.png', ONE_PIXEL_PNG); + const linkPath = path.join(allowedDir, 'linked-shot.png'); + fs.symlinkSync(targetPath, linkPath); + vi.stubEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS', allowedDir); + + const result = validateOutboundAttachments([{ path: linkPath }]); + + expect(result.files).toEqual([]); + expect(result.rejected).toEqual([ + { path: linkPath, reason: 'outside-allowed-dirs' }, + ]); + }); + it('rejects SVG attachments before inspecting file content', () => { const dir = makeTempDir(os.tmpdir(), 'ejclaw-attachment-'); const svgPath = writeFile(dir, 'vector.svg', ''); diff --git a/src/outbound-attachments.ts b/src/outbound-attachments.ts index 2787f67..b3a5ae5 100644 --- a/src/outbound-attachments.ts +++ b/src/outbound-attachments.ts @@ -3,6 +3,7 @@ import os from 'os'; import path from 'path'; import { DATA_DIR } from './config.js'; +import { getEnv } from './env.js'; import type { OutboundAttachment } from './types.js'; export interface ValidatedOutboundAttachment { @@ -32,10 +33,29 @@ function resolveExistingDir(dir: string): string | null { } } +function expandHomeDir(dir: string): string { + const home = getEnv('HOME') || os.homedir(); + if (dir === '~') return home; + if (dir.startsWith(`~${path.sep}`)) return path.join(home, dir.slice(2)); + return dir; +} + +export function getConfiguredAttachmentBaseDirs(): string[] { + const raw = getEnv('EJCLAW_ATTACHMENT_ALLOWED_DIRS') ?? ''; + return unique( + raw + .split(/[,\n]/) + .flatMap((part) => part.split(path.delimiter)) + .map((value) => value.trim()) + .filter(Boolean) + .map(expandHomeDir), + ); +} + export function getDefaultAttachmentBaseDirs(): string[] { - const home = process.env.HOME; + const home = getEnv('HOME'); const codexHome = - process.env.CODEX_HOME || (home ? path.join(home, '.codex') : null); + getEnv('CODEX_HOME') || (home ? path.join(home, '.codex') : null); // Keep defaults narrow. Runtime-specific workspaces must be passed via // attachmentBaseDirs so one room cannot attach another room's files by path. // Ad-hoc generated files under os.tmpdir() are handled separately after @@ -43,6 +63,7 @@ export function getDefaultAttachmentBaseDirs(): string[] { return unique([ path.join(DATA_DIR, 'attachments'), codexHome ? path.join(codexHome, 'generated_images') : null, + ...getConfiguredAttachmentBaseDirs(), ]) .map(resolveExistingDir) .filter((dir): dir is string => Boolean(dir)); From ef050bd312ee89f83cf146268a0a6ca1633cfc43 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Sun, 3 May 2026 23:53:15 +0900 Subject: [PATCH 14/39] fix: clean duplicate dashboard status messages --- src/channels/discord-message-cleanup.test.ts | 90 ++++++++++++++++++++ src/channels/discord-message-cleanup.ts | 81 ++++++++++++++++++ src/channels/discord.ts | 19 ++++- src/dashboard-message-cleanup.test.ts | 88 +++++++++++++++++++ src/dashboard-message-cleanup.ts | 67 +++++++++++++++ src/types.ts | 10 +++ src/unified-dashboard.test.ts | 12 +-- src/unified-dashboard.ts | 23 +++-- 8 files changed, 367 insertions(+), 23 deletions(-) create mode 100644 src/channels/discord-message-cleanup.test.ts create mode 100644 src/channels/discord-message-cleanup.ts create mode 100644 src/dashboard-message-cleanup.test.ts create mode 100644 src/dashboard-message-cleanup.ts diff --git a/src/channels/discord-message-cleanup.test.ts b/src/channels/discord-message-cleanup.test.ts new file mode 100644 index 0000000..638d896 --- /dev/null +++ b/src/channels/discord-message-cleanup.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; + +class MockMessageCollection extends Map { + filter(predicate: (message: any, id: string) => boolean) { + const filtered = new MockMessageCollection(); + for (const [id, message] of this.entries()) { + if (predicate(message, id)) filtered.set(id, message); + } + return filtered; + } +} + +describe('deleteRecentDiscordMessagesByContent', () => { + it('deletes duplicate own dashboard messages while keeping the tracked one', async () => { + const keepDelete = vi.fn(); + const duplicateDelete = vi.fn(); + const otherBotDelete = vi.fn(); + const normalDelete = vi.fn(); + const messages = new MockMessageCollection([ + [ + 'tracked', + { + id: 'tracked', + author: { id: '999888777' }, + content: '🤖 *모델 구성*\ntracked', + createdTimestamp: Date.now(), + delete: keepDelete, + }, + ], + [ + 'duplicate', + { + id: 'duplicate', + author: { id: '999888777' }, + content: '🤖 *모델 구성*\nduplicate', + createdTimestamp: Date.now(), + delete: duplicateDelete, + }, + ], + [ + 'other-bot', + { + id: 'other-bot', + author: { id: 'other-bot' }, + content: '🤖 *모델 구성*\nother bot', + createdTimestamp: Date.now(), + delete: otherBotDelete, + }, + ], + [ + 'normal', + { + id: 'normal', + author: { id: '999888777' }, + content: 'normal tracked message', + createdTimestamp: Date.now(), + delete: normalDelete, + }, + ], + ]); + const client = { + user: { id: '999888777' }, + channels: { + fetch: vi.fn().mockResolvedValue({ + messages: { + fetch: vi.fn().mockResolvedValue(messages), + }, + }), + }, + }; + + const deleted = await deleteRecentDiscordMessagesByContent({ + client: client as any, + channelName: 'discord', + jid: 'dc:1234567890123456', + options: { + contentIncludes: '🤖 *모델 구성*', + exceptMessageId: 'tracked', + }, + }); + + expect(deleted).toBe(1); + expect(duplicateDelete).toHaveBeenCalledTimes(1); + expect(keepDelete).not.toHaveBeenCalled(); + expect(otherBotDelete).not.toHaveBeenCalled(); + expect(normalDelete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/discord-message-cleanup.ts b/src/channels/discord-message-cleanup.ts new file mode 100644 index 0000000..52599f1 --- /dev/null +++ b/src/channels/discord-message-cleanup.ts @@ -0,0 +1,81 @@ +import type { Client, TextChannel } from 'discord.js'; + +import { logger } from '../logger.js'; +import type { DeleteRecentMessagesByContentOptions } from '../types.js'; + +export async function deleteRecentDiscordMessagesByContent(args: { + client: Client | null; + channelName: string; + jid: string; + options: DeleteRecentMessagesByContentOptions; +}): Promise { + if (!args.client) return 0; + + const contentIncludes = args.options.contentIncludes.trim(); + if (!contentIncludes) return 0; + + let deleted = 0; + try { + const channelId = args.jid.replace(/^dc:/, ''); + const channel = await args.client.channels.fetch(channelId); + if (!channel || !('messages' in channel)) return 0; + + const tc = channel as TextChannel; + const messages = await tc.messages.fetch({ + limit: Math.max(1, Math.min(args.options.limit ?? 100, 100)), + }); + const candidates = messages.filter( + (message) => + message.author.id === args.client?.user?.id && + message.id !== args.options.exceptMessageId && + message.content.includes(contentIncludes), + ); + if (candidates.size === 0) return 0; + + const twoWeeksAgo = Date.now() - 14 * 24 * 60 * 60 * 1000; + const recent = candidates.filter( + (message) => message.createdTimestamp > twoWeeksAgo, + ); + const old = candidates.filter( + (message) => message.createdTimestamp <= twoWeeksAgo, + ); + + if (recent.size >= 2 && 'bulkDelete' in channel) { + await tc.bulkDelete(recent); + deleted += recent.size; + } else { + for (const [, message] of recent) { + await message.delete(); + deleted += 1; + } + } + + for (const [, message] of old) { + await message.delete(); + deleted += 1; + } + + logger.info( + { + jid: args.jid, + deleted, + exceptMessageId: args.options.exceptMessageId ?? null, + channelName: args.channelName, + }, + 'Deleted duplicate Discord messages by content marker', + ); + } catch (err) { + logger.warn( + { + jid: args.jid, + err, + deleted, + exceptMessageId: args.options.exceptMessageId ?? null, + channelName: args.channelName, + }, + 'Failed to delete duplicate Discord messages by content marker', + ); + } + + return deleted; +} diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 16a32c3..e5d42c9 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -17,7 +17,12 @@ import { logger } from '../logger.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; -import type { SendMessageOptions, SendMessageResult } from '../types.js'; +import type { + DeleteRecentMessagesByContentOptions, + SendMessageOptions, + SendMessageResult, +} from '../types.js'; +import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; import { prepareDiscordOutbound } from './discord-outbound.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); @@ -760,6 +765,18 @@ export class DiscordChannel implements Channel { return deleted; } + async deleteRecentMessagesByContent( + jid: string, + options: DeleteRecentMessagesByContentOptions, + ): Promise { + return deleteRecentDiscordMessagesByContent({ + client: this.client, + channelName: this.name, + jid, + options, + }); + } + async editMessage( jid: string, messageId: string, diff --git a/src/dashboard-message-cleanup.test.ts b/src/dashboard-message-cleanup.test.ts new file mode 100644 index 0000000..4a29095 --- /dev/null +++ b/src/dashboard-message-cleanup.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + cleanupDashboardDuplicateMessages, + purgeDashboardMessages, +} from './dashboard-message-cleanup.js'; + +function makeChannel(deleteRecentMessagesByContent: any) { + return { + name: 'discord', + connect: vi.fn(), + sendMessage: vi.fn(), + isConnected: () => true, + ownsJid: () => true, + disconnect: vi.fn(), + deleteRecentMessagesByContent, + }; +} + +describe('cleanupDashboardDuplicateMessages', () => { + it('deletes recent dashboard messages except the tracked status message', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 3); + const deleted = await cleanupDashboardDuplicateMessages( + { + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }, + 'tracked-message', + ); + + expect(deleted).toBe(3); + expect(deleteRecentMessagesByContent).toHaveBeenCalledWith( + 'dc:status-channel', + { + contentIncludes: '🤖 *모델 구성*', + exceptMessageId: 'tracked-message', + limit: 100, + }, + ); + }); + + it('skips cleanup when there is no tracked status message yet', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 3); + const deleted = await cleanupDashboardDuplicateMessages( + { + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }, + null, + ); + + expect(deleted).toBe(0); + expect(deleteRecentMessagesByContent).not.toHaveBeenCalled(); + }); +}); + +describe('purgeDashboardMessages', () => { + it('deletes dashboard marker messages without purging the whole channel', async () => { + const deleteRecentMessagesByContent = vi.fn(async () => 2); + const deleted = await purgeDashboardMessages({ + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }); + + expect(deleted).toBe(2); + expect(deleteRecentMessagesByContent).toHaveBeenCalledWith( + 'dc:status-channel', + { + contentIncludes: '🤖 *모델 구성*', + limit: 100, + }, + ); + }); + + it('continues deleting dashboard batches until the last batch is partial', async () => { + const deleteRecentMessagesByContent = vi + .fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(7); + const deleted = await purgeDashboardMessages({ + statusChannelId: 'status-channel', + channels: [makeChannel(deleteRecentMessagesByContent)], + }); + + expect(deleted).toBe(107); + expect(deleteRecentMessagesByContent).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/dashboard-message-cleanup.ts b/src/dashboard-message-cleanup.ts new file mode 100644 index 0000000..46dc11a --- /dev/null +++ b/src/dashboard-message-cleanup.ts @@ -0,0 +1,67 @@ +import { logger } from './logger.js'; +import type { Channel } from './types.js'; + +const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; +const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100; + +function findDashboardCleanupChannel(opts: { + channels: Channel[]; + statusChannelId: string; +}): Channel | undefined { + if (!opts.statusChannelId) return undefined; + return opts.channels.find( + (item) => + item.name.startsWith('discord') && + item.isConnected() && + item.deleteRecentMessagesByContent, + ); +} + +export async function purgeDashboardMessages(opts: { + channels: Channel[]; + statusChannelId: string; +}): Promise { + const channel = findDashboardCleanupChannel(opts); + if (!channel?.deleteRecentMessagesByContent) return 0; + + let total = 0; + for (let i = 0; i < 10; i += 1) { + const deleted = await channel.deleteRecentMessagesByContent( + `dc:${opts.statusChannelId}`, + { + contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER, + limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT, + }, + ); + total += deleted; + if (deleted < DASHBOARD_DUPLICATE_CLEANUP_LIMIT) break; + } + return total; +} + +export async function cleanupDashboardDuplicateMessages( + opts: { channels: Channel[]; statusChannelId: string }, + keepMessageId: string | null, +): Promise { + if (!opts.statusChannelId || !keepMessageId) return 0; + + const channel = findDashboardCleanupChannel(opts); + if (!channel?.deleteRecentMessagesByContent) return 0; + + try { + return await channel.deleteRecentMessagesByContent( + `dc:${opts.statusChannelId}`, + { + contentIncludes: DASHBOARD_STATUS_MESSAGE_MARKER, + exceptMessageId: keepMessageId, + limit: DASHBOARD_DUPLICATE_CLEANUP_LIMIT, + }, + ); + } catch (err) { + logger.warn( + { err, keepMessageId }, + 'Dashboard duplicate message cleanup failed', + ); + return 0; + } +} diff --git a/src/types.ts b/src/types.ts index ce35bfe..20b1c19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -49,6 +49,12 @@ export interface SendMessageResult { visible: boolean; } +export interface DeleteRecentMessagesByContentOptions { + contentIncludes: string; + exceptMessageId?: string | null; + limit?: number; +} + export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; export type PairedTaskStatus = @@ -273,6 +279,10 @@ export interface Channel { // Optional: edit/delete messages (used by status dashboard and tracked progress cleanup). editMessage?(jid: string, messageId: string, text: string): Promise; deleteMessage?(jid: string, messageId: string): Promise; + deleteRecentMessagesByContent?( + jid: string, + options: DeleteRecentMessagesByContentOptions, + ): Promise; sendAndTrack?(jid: string, text: string): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index fbc7f0d..9a30881 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -63,22 +63,16 @@ describe('formatStatusHeader', () => { }); describe('shouldPurgeDashboardChannelOnStart', () => { - it('keeps the existing status message when a stored message id exists', () => { + it('purges on startup when explicitly requested, even with a stored message id', () => { expect( shouldPurgeDashboardChannelOnStart({ purgeOnStart: true, storedMessageId: 'status-message-1', }), - ).toBe(false); + ).toBe(true); }); - it('purges only when explicitly requested and no stored message id exists', () => { - expect( - shouldPurgeDashboardChannelOnStart({ - purgeOnStart: true, - storedMessageId: null, - }), - ).toBe(true); + it('does not purge when startup purge is disabled', () => { expect( shouldPurgeDashboardChannelOnStart({ purgeOnStart: false, diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 9aac39f..ffe726e 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -41,6 +41,10 @@ import { type DashboardRoomLine, renderCategorizedRoomSections, } from './dashboard-render.js'; +import { + cleanupDashboardDuplicateMessages, + purgeDashboardMessages, +} from './dashboard-message-cleanup.js'; import { buildClaudeUsageRows, extractCodexUsageRows, @@ -157,25 +161,14 @@ function findDiscordChannel(channels: Channel[]): Channel | undefined { export async function purgeDashboardChannel( opts: Pick, ): Promise { - if (!opts.statusChannelId) return; - - const statusJid = `dc:${opts.statusChannelId}`; - const channel = opts.channels.find( - (item) => - item.name.startsWith('discord') && - item.isConnected() && - item.purgeChannel, - ); - if (channel?.purgeChannel) { - await channel.purgeChannel(statusJid); - } + await purgeDashboardMessages(opts); } export function shouldPurgeDashboardChannelOnStart(args: { purgeOnStart?: boolean; storedMessageId: string | null; }): boolean { - return args.purgeOnStart === true && !args.storedMessageId; + return args.purgeOnStart === true; } async function refreshChannelMeta( @@ -729,6 +722,7 @@ export async function startUnifiedDashboard( }) ) { await purgeDashboardChannel(opts); + statusMessageId = null; } if (isRenderer) { @@ -787,6 +781,9 @@ export async function startUnifiedDashboard( writeDashboardStatusMessageId(opts.statusChannelId, id); } } + if (statusMessageId) { + await cleanupDashboardDuplicateMessages(opts, statusMessageId); + } if (!dashboardUpdateLogged) { logger.info( { messageId: statusMessageId, contentLength: content.length }, From f3d46ca017c5b457944e008018f3dde4628eb6ce Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 00:00:10 +0900 Subject: [PATCH 15/39] fix: poll dashboard duplicate cleanup between status updates --- src/unified-dashboard.test.ts | 8 ++++++++ src/unified-dashboard.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index 9a30881..e8a8e89 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -4,6 +4,7 @@ import type { UsageRow } from './dashboard-usage-rows.js'; import { buildWebUsageRowsForSnapshot, formatStatusHeader, + getDashboardDuplicateCleanupIntervalMs, renderUsageTable, shouldPurgeDashboardChannelOnStart, summarizeWatcherTasks, @@ -82,6 +83,13 @@ describe('shouldPurgeDashboardChannelOnStart', () => { }); }); +describe('getDashboardDuplicateCleanupIntervalMs', () => { + it('polls duplicate cleanup faster than the status update interval', () => { + expect(getDashboardDuplicateCleanupIntervalMs(10_000)).toBe(2_000); + expect(getDashboardDuplicateCleanupIntervalMs(1_000)).toBe(1_000); + }); +}); + describe('renderUsageTable', () => { const claudeRow: UsageRow = { name: 'Claude pro', diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index ffe726e..3f581f2 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -99,6 +99,7 @@ const USAGE_SNAPSHOT_MAX_AGE_MS = 600_000; * so this only affects how quickly Codex snapshot data is picked up. */ const RENDERER_USAGE_REFRESH_MS = 30_000; +const DASHBOARD_DUPLICATE_CLEANUP_POLL_MS = 2_000; let statusMessageId: string | null = null; let cachedUsageContent = ''; @@ -171,6 +172,12 @@ export function shouldPurgeDashboardChannelOnStart(args: { return args.purgeOnStart === true; } +export function getDashboardDuplicateCleanupIntervalMs( + statusUpdateInterval: number, +): number { + return Math.min(statusUpdateInterval, DASHBOARD_DUPLICATE_CLEANUP_POLL_MS); +} + async function refreshChannelMeta( opts: UnifiedDashboardOptions, ): Promise { @@ -798,6 +805,10 @@ export async function startUnifiedDashboard( }; setInterval(updateStatus, opts.statusUpdateInterval); + setInterval(() => { + if (!isRenderer || !statusMessageId) return; + void cleanupDashboardDuplicateMessages(opts, statusMessageId); + }, getDashboardDuplicateCleanupIntervalMs(opts.statusUpdateInterval)); await updateStatus(); if (isRenderer) { From f5c17d031c676a3d86dab42bc913926366025350 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 00:19:03 +0900 Subject: [PATCH 16/39] fix: delete dashboard duplicates on create --- .../discord-dashboard-create-cleanup.test.ts | 95 +++++++++++++++++++ .../discord-dashboard-create-cleanup.ts | 55 +++++++++++ src/channels/discord.test.ts | 1 + src/channels/discord.ts | 21 +++- src/dashboard-message-cleanup.ts | 2 +- 5 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 src/channels/discord-dashboard-create-cleanup.test.ts create mode 100644 src/channels/discord-dashboard-create-cleanup.ts diff --git a/src/channels/discord-dashboard-create-cleanup.test.ts b/src/channels/discord-dashboard-create-cleanup.test.ts new file mode 100644 index 0000000..5281f69 --- /dev/null +++ b/src/channels/discord-dashboard-create-cleanup.test.ts @@ -0,0 +1,95 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../config.js', () => ({ + STATUS_CHANNEL_ID: 'status-channel', +})); + +vi.mock('../logger.js', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + }, +})); + +const readDashboardStatusMessageIdMock = vi.hoisted(() => + vi.fn(() => 'dashboard-current'), +); + +vi.mock('../status-dashboard.js', () => ({ + readDashboardStatusMessageId: readDashboardStatusMessageIdMock, +})); + +import { + deleteOwnDashboardDuplicateOnCreate, + isDashboardTrackedSend, +} from './discord-dashboard-create-cleanup.js'; + +function makeDashboardMessage(overrides: { + id?: string; + channelId?: string; + content?: string; +}) { + return { + id: overrides.id ?? 'dashboard-stale', + channelId: overrides.channelId ?? 'status-channel', + content: overrides.content ?? '🤖 *모델 구성*\nMemory 3.3/91.4GB', + delete: vi.fn().mockResolvedValue(undefined), + } as any; +} + +describe('discord dashboard create cleanup', () => { + beforeEach(() => { + vi.clearAllMocks(); + readDashboardStatusMessageIdMock.mockReturnValue('dashboard-current'); + }); + + it('detects tracked dashboard sends', () => { + expect( + isDashboardTrackedSend( + 'dc:status-channel', + '🤖 *모델 구성*\nMemory 7.5/251.7GB', + ), + ).toBe(true); + expect(isDashboardTrackedSend('dc:other', '🤖 *모델 구성*')).toBe(false); + expect(isDashboardTrackedSend('dc:status-channel', 'normal')).toBe(false); + }); + + it('deletes stale own dashboard messages on create event', async () => { + const msg = makeDashboardMessage({ id: 'dashboard-stale' }); + + await deleteOwnDashboardDuplicateOnCreate({ + message: msg, + channelName: 'discord', + graceUntil: 0, + now: 100, + }); + + expect(msg.delete).toHaveBeenCalledTimes(1); + }); + + it('keeps the tracked dashboard message', async () => { + const msg = makeDashboardMessage({ id: 'dashboard-current' }); + + await deleteOwnDashboardDuplicateOnCreate({ + message: msg, + channelName: 'discord', + graceUntil: 0, + now: 100, + }); + + expect(msg.delete).not.toHaveBeenCalled(); + }); + + it('keeps dashboard messages during tracked send grace period', async () => { + const msg = makeDashboardMessage({ id: 'dashboard-new' }); + + await deleteOwnDashboardDuplicateOnCreate({ + message: msg, + channelName: 'discord', + graceUntil: 200, + now: 100, + }); + + expect(msg.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/channels/discord-dashboard-create-cleanup.ts b/src/channels/discord-dashboard-create-cleanup.ts new file mode 100644 index 0000000..9afd05b --- /dev/null +++ b/src/channels/discord-dashboard-create-cleanup.ts @@ -0,0 +1,55 @@ +import type { Message } from 'discord.js'; + +import { STATUS_CHANNEL_ID } from '../config.js'; +import { DASHBOARD_STATUS_MESSAGE_MARKER } from '../dashboard-message-cleanup.js'; +import { logger } from '../logger.js'; +import { readDashboardStatusMessageId } from '../status-dashboard.js'; + +export const DASHBOARD_TRACKED_SEND_GRACE_MS = 5000; + +export function isDashboardTrackedSend(jid: string, text: string): boolean { + return ( + Boolean(STATUS_CHANNEL_ID) && + jid.replace(/^dc:/, '') === STATUS_CHANNEL_ID && + text.includes(DASHBOARD_STATUS_MESSAGE_MARKER) + ); +} + +export async function deleteOwnDashboardDuplicateOnCreate(args: { + message: Message; + channelName: string; + graceUntil: number; + now?: number; +}): Promise { + const { message } = args; + if (!STATUS_CHANNEL_ID || message.channelId !== STATUS_CHANNEL_ID) return; + if (!message.content.includes(DASHBOARD_STATUS_MESSAGE_MARKER)) return; + + const keepMessageId = readDashboardStatusMessageId(STATUS_CHANNEL_ID); + if (!keepMessageId || message.id === keepMessageId) return; + if ((args.now ?? Date.now()) < args.graceUntil) return; + + try { + await message.delete(); + logger.info( + { + jid: `dc:${message.channelId}`, + messageId: message.id, + keepMessageId, + channelName: args.channelName, + }, + 'Deleted duplicate dashboard message on Discord create event', + ); + } catch (err) { + logger.debug( + { + jid: `dc:${message.channelId}`, + messageId: message.id, + keepMessageId, + channelName: args.channelName, + err, + }, + 'Failed to delete duplicate dashboard message on Discord create event', + ); + } +} diff --git a/src/channels/discord.test.ts b/src/channels/discord.test.ts index 4f7ecdb..3f8cf4e 100644 --- a/src/channels/discord.test.ts +++ b/src/channels/discord.test.ts @@ -29,6 +29,7 @@ vi.mock('../config.js', () => ({ TRIGGER_PATTERN: /^@Andy\b/i, DATA_DIR: '/tmp/ejclaw-test-data', CACHE_DIR: '/tmp/ejclaw-test-cache', + STATUS_CHANNEL_ID: 'status-channel', })); // Mock logger diff --git a/src/channels/discord.ts b/src/channels/discord.ts index e5d42c9..f31c985 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -22,6 +22,11 @@ import type { SendMessageOptions, SendMessageResult, } from '../types.js'; +import { + DASHBOARD_TRACKED_SEND_GRACE_MS, + deleteOwnDashboardDuplicateOnCreate, + isDashboardTrackedSend, +} from './discord-dashboard-create-cleanup.js'; import { deleteRecentDiscordMessagesByContent } from './discord-message-cleanup.js'; import { prepareDiscordOutbound } from './discord-outbound.js'; @@ -193,6 +198,7 @@ export class DiscordChannel implements Channel { private agentTypeFilter?: AgentType; private receivesInbound: boolean; private ownsDiscordJids: boolean; + private dashboardTrackedSendGraceUntil = 0; constructor( botToken: string, @@ -253,9 +259,16 @@ export class DiscordChannel implements Channel { private async handleMessageCreate(message: Message): Promise { const channelId = message.channelId; const chatJid = `dc:${channelId}`; - if (!this.receivesInbound) return; const isOwnBotMessage = message.author.id === this.client?.user?.id; - if (isOwnBotMessage) return; + if (isOwnBotMessage) { + await deleteOwnDashboardDuplicateOnCreate({ + message, + channelName: this.name, + graceUntil: this.dashboardTrackedSendGraceUntil, + }); + return; + } + if (!this.receivesInbound) return; if (message.author.bot && !hasReviewerLease(chatJid)) return; let content = message.content; @@ -635,6 +648,10 @@ export class DiscordChannel implements Channel { } try { const channelId = jid.replace(/^dc:/, ''); + if (isDashboardTrackedSend(jid, text)) { + this.dashboardTrackedSendGraceUntil = + Date.now() + DASHBOARD_TRACKED_SEND_GRACE_MS; + } const channel = await this.client.channels.fetch(channelId); if (!channel || !('send' in channel)) { throw new Error(`Discord channel not found or not text-based: ${jid}`); diff --git a/src/dashboard-message-cleanup.ts b/src/dashboard-message-cleanup.ts index 46dc11a..3ac4558 100644 --- a/src/dashboard-message-cleanup.ts +++ b/src/dashboard-message-cleanup.ts @@ -1,7 +1,7 @@ import { logger } from './logger.js'; import type { Channel } from './types.js'; -const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; +export const DASHBOARD_STATUS_MESSAGE_MARKER = '🤖 *모델 구성*'; const DASHBOARD_DUPLICATE_CLEANUP_LIMIT = 100; function findDashboardCleanupChannel(opts: { From 1576bb67df2bf6c026a6536bdd949c6447075758 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 01:37:56 +0900 Subject: [PATCH 17/39] Refine settings IA with tabbed sections --- apps/dashboard/src/MoaSettingsPanel.tsx | 7 +- apps/dashboard/src/SettingsPanel.test.ts | 13 +- apps/dashboard/src/SettingsPanel.tsx | 201 +++++++-------------- apps/dashboard/src/SettingsPanelChrome.tsx | 140 ++++++++++++++ apps/dashboard/src/styles.css | 27 ++- scripts/dashboard-ux.ts | 8 + 6 files changed, 246 insertions(+), 150 deletions(-) create mode 100644 apps/dashboard/src/SettingsPanelChrome.tsx diff --git a/apps/dashboard/src/MoaSettingsPanel.tsx b/apps/dashboard/src/MoaSettingsPanel.tsx index 8ff2f32..e277612 100644 --- a/apps/dashboard/src/MoaSettingsPanel.tsx +++ b/apps/dashboard/src/MoaSettingsPanel.tsx @@ -113,7 +113,12 @@ export function MoaSettingsPanel() { Object.values(apiKeys).some((value) => value.trim())); return ( -
+
Arbiter references

MoA 참조 모델

diff --git a/apps/dashboard/src/SettingsPanel.test.ts b/apps/dashboard/src/SettingsPanel.test.ts index 064ef1b..b6d934e 100644 --- a/apps/dashboard/src/SettingsPanel.test.ts +++ b/apps/dashboard/src/SettingsPanel.test.ts @@ -34,21 +34,20 @@ describe('SettingsPanel', () => { it('renders model, MoA, fast mode, and account controls', () => { const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps)); - expect(html).toContain('모델'); - expect(html).toContain('MoA 참조 모델'); - expect(html).toContain('패스트 모드'); - expect(html).toContain('Codex 실험 기능'); + 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-moa"'); expect(html).toContain('data-settings-target="settings-codex"'); + expect(html).toContain('data-settings-target="settings-accounts"'); expect(html).toContain('aria-controls="settings-codex"'); expect(html).not.toContain('href="#settings-codex"'); - expect(html).toContain('/goal'); 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('전체 갱신'); expect(html).toContain('스택 재시작'); expect(html.match(/class="settings-restart"/g)).toHaveLength(1); }); diff --git a/apps/dashboard/src/SettingsPanel.tsx b/apps/dashboard/src/SettingsPanel.tsx index b436760..5727c3b 100644 --- a/apps/dashboard/src/SettingsPanel.tsx +++ b/apps/dashboard/src/SettingsPanel.tsx @@ -20,8 +20,15 @@ import { updateFastMode, updateModels, } from './api'; -import { LOCALES, languageNames, type Locale, type Messages } from './i18n'; +import { type Locale, type Messages } from './i18n'; import { MoaSettingsPanel } from './MoaSettingsPanel'; +import { + GeneralSettings, + SettingsApplyCard, + SettingsNav, + SettingsSectionHeading, + type SettingsSectionId, +} from './SettingsPanelChrome'; type AccountProvider = 'claude' | 'codex'; @@ -31,18 +38,6 @@ interface AccountData { codexCurrentIndex?: number; } -const SETTINGS_NAV_ITEMS = [ - { targetId: 'settings-general', title: '일반', detail: '표시 · 언어' }, - { - targetId: 'settings-models', - title: '모델', - detail: 'owner · reviewer · arbiter', - }, - { targetId: 'settings-moa', title: 'MoA', detail: '참조 모델 · 연결 테스트' }, - { targetId: 'settings-codex', title: 'Codex', detail: 'fast mode · /goal' }, - { targetId: 'settings-accounts', title: '계정', detail: 'Claude · Codex' }, -] as const; - export interface SettingsPanelProps { locale: Locale; nickname: string; @@ -60,142 +55,51 @@ export function SettingsPanel({ onRestartStack, t, }: SettingsPanelProps) { + const [activeSection, setActiveSection] = + useState('settings-general'); + return (
-
-
+
- + - + -
- -
- - -
-
+ - +
); } -function SettingsNav() { - const scrollToSection = (targetId: string) => { - document - .getElementById(targetId) - ?.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - - return ( - - ); -} - -function SettingsSectionHeading({ - detail, - title, - description, -}: { - detail: string; - title: string; - description: string; -}) { - return ( -
- {detail} -

{title}

-

{description}

-
- ); -} - -function SettingsApplyCard({ onRestartStack }: { onRestartStack: () => void }) { - return ( -
-
- Apply - 저장 후 재시작 - 모델, MoA, Codex, 계정 변경은 스택 재시작 후 반영됩니다. -
- -
- ); -} - function ModelSettings() { const [config, setConfig] = useState(null); const [draft, setDraft] = useState(null); @@ -261,7 +165,12 @@ function ModelSettings() { JSON.stringify(draft) !== JSON.stringify(config); return ( -
+
+ +
+ + +
+
+ ); +} + function FastModeSettings() { const [state, setState] = useState(null); const [busy, setBusy] = useState(false); @@ -611,7 +541,12 @@ function AccountSettings() { } return ( -
+
void; +}) { + return ( +
+ {SETTINGS_NAV_ITEMS.map((item) => ( + + ))} +
+ ); +} + +export function GeneralSettings({ + locale, + nickname, + onLocaleChange, + onNicknameChange, + t, +}: { + locale: Locale; + nickname: string; + onLocaleChange: (locale: Locale) => void; + onNicknameChange: (next: string) => void; + t: Messages; +}) { + return ( +
+ +
+ + +
+
+ ); +} + +export function SettingsSectionHeading({ + detail, + title, + description, +}: { + detail: string; + title: string; + description: string; +}) { + return ( +
+ {detail} +

{title}

+

{description}

+
+ ); +} + +export function SettingsApplyCard({ + onRestartStack, +}: { + onRestartStack: () => void; +}) { + return ( +
+
+ Apply + 저장 후 재시작 + 모델, MoA, Codex, 계정 변경은 스택 재시작 후 반영됩니다. +
+ +
+ ); +} diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 00b2069..dc5a1cc 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -669,6 +669,7 @@ button:disabled { .settings-nav button { display: grid; gap: 2px; + min-height: 52px; padding: 10px 12px; border: 1px solid transparent; border-radius: 12px; @@ -685,7 +686,8 @@ button:disabled { } .settings-nav button:hover, -.settings-nav button:focus-visible { +.settings-nav button:focus-visible, +.settings-nav button[aria-selected='true'] { transform: translateX(2px); border-color: rgba(214, 130, 88, 0.24); background: rgba(214, 130, 88, 0.08); @@ -728,7 +730,7 @@ button:disabled { .settings-row input, .settings-row select { - min-height: 38px; + min-height: 44px; padding: 0 12px; border: 1px solid var(--panel-border); border-radius: 10px; @@ -929,7 +931,7 @@ button:disabled { .settings-save, .settings-restart { - min-height: 32px; + min-height: 44px; padding: 0 14px; border: 0; border-radius: 999px; @@ -1006,7 +1008,7 @@ button:disabled { } .settings-secondary { - min-height: 28px; + min-height: 36px; padding: 0 10px; border: 1px solid var(--panel-border-strong); border-radius: 999px; @@ -1050,7 +1052,7 @@ button:disabled { .settings-moa-grid input, .settings-moa-grid select { - min-height: 32px; + min-height: 40px; min-width: 0; padding: 0 10px; border: 1px solid var(--panel-border); @@ -1141,7 +1143,7 @@ button:disabled { } .settings-delete { - min-height: 28px; + min-height: 36px; padding: 0 10px; border: 1px solid rgba(224, 100, 75, 0.32); border-radius: 999px; @@ -1178,7 +1180,7 @@ button:disabled { .settings-add-token button { align-self: end; - min-height: 36px; + min-height: 44px; padding: 0 14px; border: 0; border-radius: 999px; @@ -1204,8 +1206,7 @@ button:disabled { } .settings-nav { - grid-template-columns: repeat(5, minmax(120px, 1fr)); - overflow-x: auto; + grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); } .settings-apply-card { @@ -1215,6 +1216,14 @@ button:disabled { } @media (max-width: 720px) { + .settings-nav { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .settings-nav button { + min-height: 58px; + } + .settings-apply-card { align-items: stretch; grid-template-columns: 1fr; diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 6064971..cb81f7f 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -67,6 +67,7 @@ async function main() { baseUrl, async (page, state) => { await openSettings(page, baseUrl); + await openSettingsSection(page, 'settings-codex'); const goalToggle = page.getByRole('checkbox', { name: /\/goal/ }); await assertVisible(goalToggle); @@ -236,6 +237,13 @@ async function openSettings(page: Page, baseUrl: string) { ); } +async function openSettingsSection(page: Page, targetId: string) { + await page + .locator(`.settings-nav button[data-settings-target="${targetId}"]`) + .click(); + await assertVisible(page.locator(`#${targetId}`)); +} + async function assertVisible(locator: ReturnType) { await locator.waitFor({ state: 'visible', timeout: 5_000 }); } From 20c06ea5a47e441a2e6890afd879448412365014 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 02:08:21 +0900 Subject: [PATCH 18/39] Add read-only runtime inventory settings --- .../src/RuntimeInventorySettings.css | 130 +++++++++++ .../src/RuntimeInventorySettings.tsx | 174 ++++++++++++++ apps/dashboard/src/SettingsPanel.test.ts | 5 +- apps/dashboard/src/SettingsPanel.tsx | 5 + apps/dashboard/src/SettingsPanelChrome.tsx | 5 + apps/dashboard/src/api.ts | 50 +++++ scripts/dashboard-ux.ts | 108 +++++++++ src/runtime-inventory.test.ts | 91 ++++++++ src/runtime-inventory.ts | 212 ++++++++++++++++++ src/web-dashboard-settings-routes.test.ts | 92 ++++++++ src/web-dashboard-settings-routes.ts | 19 ++ 11 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/src/RuntimeInventorySettings.css create mode 100644 apps/dashboard/src/RuntimeInventorySettings.tsx create mode 100644 src/runtime-inventory.test.ts create mode 100644 src/runtime-inventory.ts 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 ( +
    +
    +
    + {dir.label} + {dir.path} +
    + + {dir.count} skills + +
    + {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} +
    +
    + + + + +
    +
    +

    EJClaw bridge

    + +
    +
      + + +
    +
    +
    + )} +
    + ); +} 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, }; From f958b361d754fe9d40d28a2d58e2acbb5b5b6d2a Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 02:34:14 +0900 Subject: [PATCH 19/39] Fix runtime inventory MCP JSON parsing --- src/runtime-inventory.test.ts | 19 ++++++++++++- src/runtime-inventory.ts | 50 ++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/runtime-inventory.test.ts b/src/runtime-inventory.test.ts index e214aa1..d67c9c7 100644 --- a/src/runtime-inventory.test.ts +++ b/src/runtime-inventory.test.ts @@ -34,7 +34,19 @@ describe('runtime inventory', () => { ); fs.writeFileSync( path.join(claudeDir, 'settings.json'), - '{"fastMode":true,"secret":"should-not-leak"}\n', + JSON.stringify({ + fastMode: true, + secret: 'should-not-leak', + mcpServers: { + ejclaw: { + command: 'node', + args: ['secret-arg-should-not-leak'], + }, + filesystem: { + command: 'node', + }, + }, + }), ); fs.writeFileSync( path.join(codexDir, 'auth.json'), @@ -75,6 +87,10 @@ describe('runtime inventory', () => { ejclawConfigured: true, serverCount: 2, }); + expect(snapshot.claude.mcp).toMatchObject({ + ejclawConfigured: true, + serverCount: 2, + }); expect(snapshot.codex.skillDirs[0]).toMatchObject({ count: 1, skills: [{ name: 'browser', description: 'Browser automation' }], @@ -87,5 +103,6 @@ describe('runtime inventory', () => { const serialized = JSON.stringify(snapshot); expect(serialized).not.toContain('sk-secret'); expect(serialized).not.toContain('should-not-leak'); + expect(serialized).not.toContain('secret-arg-should-not-leak'); }); }); diff --git a/src/runtime-inventory.ts b/src/runtime-inventory.ts index f6d83c1..c89f4ed 100644 --- a/src/runtime-inventory.ts +++ b/src/runtime-inventory.ts @@ -61,6 +61,11 @@ interface RuntimeInventoryOptions { generatedAt?: string; } +interface McpConfigSummary { + ejclawConfigured: boolean; + serverCount: number; +} + function pathSnapshot(label: string, filePath: string): RuntimePathSnapshot { return { label, @@ -119,6 +124,42 @@ function readSkillDir(label: string, dirPath: string): RuntimeSkillDirSnapshot { }; } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function summarizeTomlMcpConfig(content: string): McpConfigSummary { + const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? []; + return { + ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content), + serverCount: serverMatches.length, + }; +} + +function summarizeJsonMcpConfig(content: string): McpConfigSummary | null { + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + + const servers = parsed.mcpServers ?? parsed.mcp_servers; + if (!isRecord(servers)) { + return { + ejclawConfigured: false, + serverCount: 0, + }; + } + + const names = Object.keys(servers); + return { + ejclawConfigured: names.includes('ejclaw'), + serverCount: names.length, + }; +} + function readMcpSnapshot( label: string, configPath: string, @@ -132,11 +173,14 @@ function readMcpSnapshot( } } - const serverMatches = content.match(/^\s*\[mcp_servers\.[^\]]+\]/gm) ?? []; + const summary = + path.extname(configPath) === '.json' + ? (summarizeJsonMcpConfig(content) ?? summarizeTomlMcpConfig(content)) + : summarizeTomlMcpConfig(content); + return { configPath: pathSnapshot(label, configPath), - ejclawConfigured: /^\s*\[mcp_servers\.ejclaw(?:\.[^\]]+)?\]/m.test(content), - serverCount: serverMatches.length, + ...summary, }; } From ce6c863349979cac2a048961b05dcb9eeaad7a58 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 03:03:05 +0900 Subject: [PATCH 20/39] Add room skill settings inventory --- .../src/RuntimeInventorySettings.css | 63 ++++- .../src/RuntimeInventorySettings.tsx | 104 +++++++- apps/dashboard/src/api.ts | 39 +++ scripts/dashboard-ux.ts | 75 ++++++ src/db.ts | 1 + src/db/base-schema.ts | 24 +- src/db/bootstrap.test.ts | 1 + src/db/migrations/016_room-skill-overrides.ts | 29 +++ src/db/migrations/index.ts | 2 + src/db/rooms.ts | 66 +++++ src/db/runtime-tasks-rooms.ts | 10 + src/room-skill-settings.test.ts | 195 ++++++++++++++ src/room-skill-settings.ts | 239 ++++++++++++++++++ src/web-dashboard-settings-routes.test.ts | 41 +++ src/web-dashboard-settings-routes.ts | 19 ++ 15 files changed, 902 insertions(+), 6 deletions(-) create mode 100644 src/db/migrations/016_room-skill-overrides.ts create mode 100644 src/room-skill-settings.test.ts create mode 100644 src/room-skill-settings.ts diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css index a35d15e..c67465e 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.css +++ b/apps/dashboard/src/RuntimeInventorySettings.css @@ -114,9 +114,67 @@ 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) { .runtime-summary-card, - .runtime-skill-grid { + .runtime-skill-grid, + .runtime-room-agent-grid { grid-template-columns: 1fr; } } @@ -124,7 +182,8 @@ @media (max-width: 720px) { .runtime-card-head, .runtime-skill-card header, - .runtime-path-row { + .runtime-path-row, + .runtime-room-skill-card > header { flex-direction: column; } } diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx index 3b4f51b..7753f94 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.tsx +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react'; import { + fetchRoomSkillSettings, fetchRuntimeInventory, type RuntimeAgentInventory, type RuntimeInventorySnapshot, type RuntimePathSnapshot, type RuntimeSkillDirSnapshot, + type RoomSkillCatalogItem, + type RoomSkillSettingsSnapshot, } from './api'; import { SettingsSectionHeading } from './SettingsPanelChrome'; 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 ( +
    +
    +

    방별 스킬 정책

    +
    +

    방별 스킬 정책을 불러오는 중…

    +
    + ); + } + + const catalogById = new Map( + snapshot.catalog.map((skill) => [skill.id, skill]), + ); + const roomPreview = snapshot.rooms.slice(0, 8); + + return ( +
    +
    +
    +

    방별 스킬 정책

    +

    + 전역 설정은 읽기 전용입니다. 이 목록은 다음 토글 PR에서 방 단위 + enable/disable 대상으로 쓰입니다. +

    +
    + + {snapshot.catalog.length} skills · {snapshot.rooms.length} rooms + +
    + + {roomPreview.length === 0 ? ( +

    등록된 방이 없습니다.

    + ) : ( +
    + {roomPreview.map((room) => ( +
    +
    +
    + {room.name} + {room.folder} +
    + {room.jid} +
    +
    + {room.agents.map((agent) => { + const disabledNames = agent.disabledSkillIds + .map((id) => catalogById.get(id)?.displayName ?? id) + .slice(0, 3); + return ( +
    + {agentLabel(agent.agentType)} + + {agent.mode === 'all-enabled' + ? '기본 전체 ON' + : `${agent.disabledSkillIds.length}개 OFF`} + + + 사용 {agent.effectiveEnabledSkillIds.length} / 가능{' '} + {agent.availableSkillIds.length} + + {disabledNames.length > 0 ? ( + OFF: {disabledNames.join(', ')} + ) : null} +
    + ); + })} +
    +
    + ))} +
    + )} + {snapshot.rooms.length > roomPreview.length ? ( + + 외 {snapshot.rooms.length - roomPreview.length}개 방 더 있음 + + ) : null} +
    + ); +} + export function RuntimeInventorySettings() { const [snapshot, setSnapshot] = useState( null, ); + const [roomSkillSnapshot, setRoomSkillSnapshot] = + useState(null); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; - fetchRuntimeInventory() - .then((value) => { + Promise.all([fetchRuntimeInventory(), fetchRoomSkillSettings()]) + .then(([value, roomSkills]) => { if (cancelled) return; setSnapshot(value); + setRoomSkillSnapshot(roomSkills); setError(null); }) .catch((err) => { @@ -156,6 +255,7 @@ export function RuntimeInventorySettings() { +
    diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index d7e5776..ce1dbe6 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -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 { model: string; checkedAt: string; @@ -596,6 +631,10 @@ export async function fetchRuntimeInventory(): Promise return fetchJson('/api/settings/runtime-inventory'); } +export async function fetchRoomSkillSettings(): Promise { + return fetchJson('/api/settings/room-skills'); +} + export async function updateCodexFeatures( input: Partial, ): Promise { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index e14ad6e..28071bc 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -319,6 +319,11 @@ async function handleMockApi(route: Route, state: MockApiState) { return; } + if (method === 'GET' && url.pathname === '/api/settings/room-skills') { + await fulfillJson(route, roomSkillSettingsFixture()); + return; + } + if (url.pathname === '/api/settings/codex-features') { if (method === 'GET') { 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 { if (!body) return {}; const parsed = JSON.parse(body) as unknown; diff --git a/src/db.ts b/src/db.ts index 83ca622..34b2704 100644 --- a/src/db.ts +++ b/src/db.ts @@ -62,6 +62,7 @@ export { getSessionForAgentType, getStoredRoomRoleAgentPlan, getStoredRoomSettings, + getStoredRoomSkillOverrides, getTaskById, getTasksForGroup, hasActiveCiWatcherForChat, diff --git a/src/db/base-schema.ts b/src/db/base-schema.ts index 7806d61..02bd003 100644 --- a/src/db/base-schema.ts +++ b/src/db/base-schema.ts @@ -1,5 +1,25 @@ 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 { database.exec(` CREATE TABLE IF NOT EXISTS chats ( @@ -410,8 +430,7 @@ export function applyBaseSchema(database: Database): void { last_used_at TEXT, archived_at TEXT ); - CREATE INDEX IF NOT EXISTS idx_memories_scope - ON memories(scope_kind, scope_key); + CREATE INDEX IF NOT EXISTS idx_memories_scope ON memories(scope_kind, scope_key); CREATE INDEX IF NOT EXISTS idx_memories_active ON memories(scope_kind, scope_key, archived_at, created_at); 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); END; `); + database.exec(ROOM_SKILL_OVERRIDES_SCHEMA); } diff --git a/src/db/bootstrap.test.ts b/src/db/bootstrap.test.ts index f0c20ce..6015b98 100644 --- a/src/db/bootstrap.test.ts +++ b/src/db/bootstrap.test.ts @@ -40,6 +40,7 @@ function getExpectedSchemaMigrations(): Array<{ { version: 13, name: 'message_source_kind' }, { version: 14, name: 'work_item_attachments' }, { version: 15, name: 'turn_progress_text' }, + { version: 16, name: 'room_skill_overrides' }, ]; } diff --git a/src/db/migrations/016_room-skill-overrides.ts b/src/db/migrations/016_room-skill-overrides.ts new file mode 100644 index 0000000..e94c81a --- /dev/null +++ b/src/db/migrations/016_room-skill-overrides.ts @@ -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); + `); + }, +}; diff --git a/src/db/migrations/index.ts b/src/db/migrations/index.ts index f3e8fee..6ccb029 100644 --- a/src/db/migrations/index.ts +++ b/src/db/migrations/index.ts @@ -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 { WORK_ITEM_ATTACHMENTS_MIGRATION } from './014_work-item-attachments.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 { SchemaMigrationArgs, SchemaMigrationDefinition, @@ -38,6 +39,7 @@ const ORDERED_SCHEMA_MIGRATIONS: readonly SchemaMigrationDefinition[] = [ MESSAGE_SOURCE_KIND_MIGRATION, WORK_ITEM_ATTACHMENTS_MIGRATION, TURN_PROGRESS_TEXT_MIGRATION, + ROOM_SKILL_OVERRIDES_MIGRATION, ]; function ensureSchemaMigrationsTable(database: Database): void { diff --git a/src/db/rooms.ts b/src/db/rooms.ts index f50f933..f9d9640 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -28,6 +28,16 @@ interface StoredRoomModeRow { source: RoomModeSource; } +export interface StoredRoomSkillOverride { + chatJid: string; + agentType: AgentType; + skillScope: string; + skillName: string; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + export interface AssignRoomInput { name: string; roomMode?: RoomMode; @@ -307,6 +317,62 @@ export function getStoredRoomRoleAgentPlanFromDatabase( 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( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index 6f94077..a1a8b7e 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -36,9 +36,11 @@ import { getRegisteredGroupFromDatabase, getStoredRoomRoleAgentPlanFromDatabase, getStoredRoomSettingsFromDatabase, + getStoredRoomSkillOverridesFromDatabase, setExplicitRoomModeInDatabase, setRegisteredGroupForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase, + type StoredRoomSkillOverride, updateRegisteredGroupNameInDatabase, } from './rooms.js'; import { type StoredRoomSettings } from './room-registration.js'; @@ -363,6 +365,14 @@ export function getStoredRoomRoleAgentPlan( 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 { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/room-skill-settings.test.ts b/src/room-skill-settings.test.ts new file mode 100644 index 0000000..813b873 --- /dev/null +++ b/src/room-skill-settings.test.ts @@ -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(); + } + }); +}); diff --git a/src/room-skill-settings.ts b/src/room-skill-settings.ts new file mode 100644 index 0000000..448a064 --- /dev/null +++ b/src/room-skill-settings.ts @@ -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; + registeredAgentTypesByJid?: Map; + 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, + 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([...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(); + 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, +): AgentType[] { + const registered = registeredAgentTypesByJid?.get(jid) ?? []; + const types = new Set(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(), + }); +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index ebdaeaa..f3ca628 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -12,6 +12,7 @@ import type { ModelConfigSnapshot, } from './settings-store.js'; import type { RuntimeInventorySnapshot } from './runtime-inventory.js'; +import type { RoomSkillSettingsSnapshot } from './room-skill-settings.js'; import type { MoaSettingsSnapshot } from './settings-store-moa.js'; 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 { return { index: 0, @@ -197,6 +230,7 @@ function makeDeps( getModelConfig: () => modelConfig, getMoaSettings: () => moaSettings, getRuntimeInventory: () => runtimeInventory, + getRoomSkillSettings: () => roomSkillSettings, listClaudeAccounts: () => [makeClaudeAccount()], listCodexAccounts: () => [makeCodexAccount()], refreshAllCodexAccounts: async () => ({ refreshed: [1], failed: [] }), @@ -251,6 +285,13 @@ describe('web dashboard settings routes', () => { 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'); 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 a4ca32e..0468153 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -23,6 +23,10 @@ import { getRuntimeInventory, type RuntimeInventorySnapshot, } from './runtime-inventory.js'; +import { + getRoomSkillSettings, + type RoomSkillSettingsSnapshot, +} from './room-skill-settings.js'; import { checkMoaModel, getMoaSettings, @@ -45,6 +49,7 @@ export interface SettingsRouteDependencies { getModelConfig: typeof getModelConfig; getMoaSettings: typeof getMoaSettings; getRuntimeInventory: typeof getRuntimeInventory; + getRoomSkillSettings: typeof getRoomSkillSettings; listClaudeAccounts: typeof listClaudeAccounts; listCodexAccounts: typeof listCodexAccounts; refreshAllCodexAccounts: typeof refreshAllCodexAccounts; @@ -73,6 +78,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { getModelConfig, getMoaSettings, getRuntimeInventory, + getRoomSkillSettings, listClaudeAccounts, listCodexAccounts, refreshAllCodexAccounts, @@ -222,6 +228,15 @@ function handleRuntimeInventoryRoute( 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( request: Request, jsonResponse: JsonResponse, @@ -338,6 +353,9 @@ export async function handleSettingsRoute({ if (url.pathname === '/api/settings/runtime-inventory') { return handleRuntimeInventoryRoute(request, jsonResponse, deps); } + if (url.pathname === '/api/settings/room-skills') { + return handleRoomSkillsRoute(request, jsonResponse, deps); + } if (url.pathname === '/api/settings/fast-mode') { return handleFastModeRoute(request, jsonResponse, deps); } @@ -385,4 +403,5 @@ export type { ModelConfigSnapshot, MoaSettingsSnapshot, RuntimeInventorySnapshot, + RoomSkillSettingsSnapshot, }; From 55c250cbf165c728107a9c39225971faa0a31d2e Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 03:23:38 +0900 Subject: [PATCH 21/39] Add room skill setting mutations --- .../src/RuntimeInventorySettings.css | 37 ++++++++ .../src/RuntimeInventorySettings.tsx | 69 +++++++++++++- apps/dashboard/src/api.ts | 31 +++++++ scripts/dashboard-ux.ts | 61 ++++++++++-- src/db.ts | 2 + src/db/rooms.ts | 58 ++++++++++++ src/db/runtime-tasks-rooms.ts | 15 +++ src/room-skill-settings.test.ts | 93 ++++++++++++++++++- src/room-skill-settings.ts | 92 +++++++++++++++++- src/web-dashboard-settings-routes.test.ts | 81 ++++++++++++++++ src/web-dashboard-settings-routes.ts | 55 ++++++++++- 11 files changed, 577 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/src/RuntimeInventorySettings.css b/apps/dashboard/src/RuntimeInventorySettings.css index c67465e..c40e826 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.css +++ b/apps/dashboard/src/RuntimeInventorySettings.css @@ -171,6 +171,43 @@ font-size: 0.86rem; } +.runtime-room-skill-toggles { + display: grid; + gap: 8px; + margin-top: 8px; +} + +.runtime-room-skill-toggle { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 10px; + min-height: 44px; + border-radius: 12px; + background: rgba(15, 23, 42, 0.38); + padding: 8px 10px; +} + +.runtime-room-skill-toggle input { + width: 18px; + height: 18px; +} + +.runtime-room-skill-toggle > span { + display: grid; + gap: 2px; + min-width: 0; +} + +.runtime-room-skill-toggle strong { + color: var(--text); + font-size: 0.84rem; +} + +.runtime-room-skill-toggle small { + color: var(--muted); +} + @media (max-width: 980px) { .runtime-summary-card, .runtime-skill-grid, diff --git a/apps/dashboard/src/RuntimeInventorySettings.tsx b/apps/dashboard/src/RuntimeInventorySettings.tsx index 7753f94..af7a1d3 100644 --- a/apps/dashboard/src/RuntimeInventorySettings.tsx +++ b/apps/dashboard/src/RuntimeInventorySettings.tsx @@ -3,11 +3,13 @@ import { useEffect, useState } from 'react'; import { fetchRoomSkillSettings, fetchRuntimeInventory, + updateRoomSkillSetting, type RuntimeAgentInventory, type RuntimeInventorySnapshot, type RuntimePathSnapshot, type RuntimeSkillDirSnapshot, type RoomSkillCatalogItem, + type RoomSkillSettingUpdateInput, type RoomSkillSettingsSnapshot, } from './api'; import { SettingsSectionHeading } from './SettingsPanelChrome'; @@ -106,8 +108,12 @@ function agentLabel(agentType: 'claude-code' | 'codex') { } function RoomSkillPolicyCard({ + onToggle, + savingKey, snapshot, }: { + onToggle: (input: RoomSkillSettingUpdateInput) => void; + savingKey: string | null; snapshot: RoomSkillSettingsSnapshot | null; }) { if (!snapshot) { @@ -159,6 +165,7 @@ function RoomSkillPolicyCard({ const disabledNames = agent.disabledSkillIds .map((id) => catalogById.get(id)?.displayName ?? id) .slice(0, 3); + const enabledIds = new Set(agent.effectiveEnabledSkillIds); return (
    0 ? ( OFF: {disabledNames.join(', ')} ) : null} +
    + {agent.availableSkillIds.map((skillId) => { + const skill = catalogById.get(skillId); + const key = `${room.jid}:${agent.agentType}:${skillId}`; + return ( + + ); + })} +
    ); })} @@ -201,6 +241,10 @@ export function RuntimeInventorySettings() { const [roomSkillSnapshot, setRoomSkillSnapshot] = useState(null); const [error, setError] = useState(null); + const [roomSkillError, setRoomSkillError] = useState(null); + const [savingRoomSkillKey, setSavingRoomSkillKey] = useState( + null, + ); useEffect(() => { let cancelled = false; @@ -220,6 +264,20 @@ export function RuntimeInventorySettings() { }; }, []); + async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) { + const key = `${input.roomJid}:${input.agentType}:${input.skillId}`; + setSavingRoomSkillKey(key); + setRoomSkillError(null); + try { + const next = await updateRoomSkillSetting(input); + setRoomSkillSnapshot(next); + } catch (err) { + setRoomSkillError(err instanceof Error ? err.message : String(err)); + } finally { + setSavingRoomSkillKey(null); + } + } + return (
    - + {roomSkillError ? ( +

    {roomSkillError}

    + ) : null} + { + void handleRoomSkillToggle(input); + }} + savingKey={savingRoomSkillKey} + snapshot={roomSkillSnapshot} + />
    diff --git a/apps/dashboard/src/api.ts b/apps/dashboard/src/api.ts index ce1dbe6..02ffd57 100644 --- a/apps/dashboard/src/api.ts +++ b/apps/dashboard/src/api.ts @@ -473,6 +473,13 @@ export interface RoomSkillSettingsSnapshot { rooms: RoomSkillPolicyRoom[]; } +export interface RoomSkillSettingUpdateInput { + roomJid: string; + agentType: 'claude-code' | 'codex'; + skillId: string; + enabled: boolean; +} + export interface MoaReferenceStatus { model: string; checkedAt: string; @@ -635,6 +642,30 @@ export async function fetchRoomSkillSettings(): Promise { + const response = await fetch('/api/settings/room-skills', { + method: 'PATCH', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + body: JSON.stringify(input), + }); + if (!response.ok) { + let msg = `update room skill failed: ${response.status}`; + try { + const payload = (await response.json()) as { error?: string }; + if (payload.error) msg = payload.error; + } catch { + /* ignore */ + } + throw new Error(msg); + } + return (await response.json()) as RoomSkillSettingsSnapshot; +} + export async function updateCodexFeatures( input: Partial, ): Promise { diff --git a/scripts/dashboard-ux.ts b/scripts/dashboard-ux.ts index 28071bc..2f6b0ea 100644 --- a/scripts/dashboard-ux.ts +++ b/scripts/dashboard-ux.ts @@ -11,6 +11,8 @@ interface MockApiState { codexFeatureUpdates: number; ciWatcherFailures: number; restartRequests: number; + roomSkillUpdates: number; + roomSkillsDisableCodexBrowser: boolean; } const seriousImpacts = new Set(['serious', 'critical']); @@ -27,7 +29,7 @@ async function main() { 'settings nav keeps hash route stable', browser, baseUrl, - async (page) => { + async (page, state) => { await openSettings(page, baseUrl); const originalUrl = page.url(); @@ -52,6 +54,17 @@ async function main() { await assertVisible(page.locator('#settings-runtime')); await page.getByText('Runtime inventory').waitFor(); await page.getByText('EJClaw bridge').waitFor(); + const codexPolicy = page + .locator('.runtime-room-agent-policy') + .filter({ hasText: 'Codex' }) + .first(); + const codexBrowserToggle = codexPolicy + .locator('input[type="checkbox"]') + .first(); + assert.equal(await codexBrowserToggle.isChecked(), true); + await codexBrowserToggle.click(); + await codexPolicy.getByText('1개 OFF').waitFor(); + assert.equal(state.roomSkillUpdates, 1); await page .locator( @@ -225,6 +238,8 @@ function createMockApiState(): MockApiState { codexFeatureUpdates: 0, ciWatcherFailures: 0, restartRequests: 0, + roomSkillUpdates: 0, + roomSkillsDisableCodexBrowser: false, }; } @@ -319,8 +334,7 @@ async function handleMockApi(route: Route, state: MockApiState) { return; } - if (method === 'GET' && url.pathname === '/api/settings/room-skills') { - await fulfillJson(route, roomSkillSettingsFixture()); + if (await handleMockRoomSkillRoute(route, state, url, method)) { return; } @@ -419,6 +433,33 @@ async function handleMockApi(route: Route, state: MockApiState) { ); } +async function handleMockRoomSkillRoute( + route: Route, + state: MockApiState, + url: URL, + method: string, +): Promise { + if (url.pathname !== '/api/settings/room-skills') return false; + if (method === 'GET') { + await fulfillJson(route, roomSkillSettingsFixture(state)); + return true; + } + if (method !== 'PATCH' && method !== 'PUT') return false; + + const body = parseJsonBody(route.request().postData()); + if ( + body.roomJid === 'room@example' && + body.agentType === 'codex' && + body.skillId === 'codex-user:agent-browser' && + body.enabled === false + ) { + state.roomSkillUpdates += 1; + state.roomSkillsDisableCodexBrowser = true; + } + await fulfillJson(route, roomSkillSettingsFixture(state)); + return true; +} + function mockOverview(state: MockApiState) { return { generatedAt: MOCK_TIME, @@ -588,7 +629,8 @@ function runtimeInventoryFixture() { }; } -function roomSkillSettingsFixture() { +function roomSkillSettingsFixture(state?: MockApiState) { + const codexDisabled = state?.roomSkillsDisableCodexBrowser === true; return { generatedAt: '2026-05-04T00:00:00.000Z', catalog: [ @@ -629,17 +671,16 @@ function roomSkillSettingsFixture() { agents: [ { agentType: 'codex', - mode: 'all-enabled', + mode: codexDisabled ? 'custom' : 'all-enabled', availableSkillIds: [ 'codex-user:agent-browser', 'runner:agent-browser', ], - disabledSkillIds: [], + disabledSkillIds: codexDisabled ? ['codex-user:agent-browser'] : [], explicitEnabledSkillIds: [], - effectiveEnabledSkillIds: [ - 'codex-user:agent-browser', - 'runner:agent-browser', - ], + effectiveEnabledSkillIds: codexDisabled + ? ['runner:agent-browser'] + : ['codex-user:agent-browser', 'runner:agent-browser'], }, { agentType: 'claude-code', diff --git a/src/db.ts b/src/db.ts index 34b2704..90f346c 100644 --- a/src/db.ts +++ b/src/db.ts @@ -43,6 +43,7 @@ export { createTask, deleteAllSessionsForGroup, deleteSession, + deleteStoredRoomSkillOverride, deleteTask, findDuplicateCiWatcher, getAllRoomBindings, @@ -72,6 +73,7 @@ export { setRouterStateForService, setSession, setSessionForAgentType, + upsertStoredRoomSkillOverride, updateRegisteredGroupName, updateTask, updateTaskAfterRun, diff --git a/src/db/rooms.ts b/src/db/rooms.ts index f9d9640..940f12b 100644 --- a/src/db/rooms.ts +++ b/src/db/rooms.ts @@ -38,6 +38,14 @@ export interface StoredRoomSkillOverride { updatedAt: string; } +export interface StoredRoomSkillOverrideInput { + chatJid: string; + agentType: AgentType; + skillScope: string; + skillName: string; + enabled: boolean; +} + export interface AssignRoomInput { name: string; roomMode?: RoomMode; @@ -373,6 +381,56 @@ export function getStoredRoomSkillOverridesFromDatabase( .filter((row): row is StoredRoomSkillOverride => Boolean(row)); } +export function upsertStoredRoomSkillOverrideInDatabase( + database: Database, + input: StoredRoomSkillOverrideInput, +): void { + const agentType = normalizeStoredAgentType(input.agentType); + if (!agentType) { + throw new Error(`Unsupported agent type: ${input.agentType}`); + } + const now = new Date().toISOString(); + database + .prepare( + `INSERT INTO room_skill_overrides ( + chat_jid, agent_type, skill_scope, skill_name, enabled, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(chat_jid, agent_type, skill_scope, skill_name) + DO UPDATE SET + enabled = excluded.enabled, + updated_at = excluded.updated_at`, + ) + .run( + input.chatJid, + agentType, + input.skillScope, + input.skillName, + input.enabled ? 1 : 0, + now, + now, + ); +} + +export function deleteStoredRoomSkillOverrideFromDatabase( + database: Database, + input: Omit, +): void { + const agentType = normalizeStoredAgentType(input.agentType); + if (!agentType) { + throw new Error(`Unsupported agent type: ${input.agentType}`); + } + database + .prepare( + `DELETE FROM room_skill_overrides + WHERE chat_jid = ? + AND agent_type = ? + AND skill_scope = ? + AND skill_name = ?`, + ) + .run(input.chatJid, agentType, input.skillScope, input.skillName); +} + function getStoredRoomModeRowFromDatabase( database: Database, chatJid: string, diff --git a/src/db/runtime-tasks-rooms.ts b/src/db/runtime-tasks-rooms.ts index a1a8b7e..45ccca6 100644 --- a/src/db/runtime-tasks-rooms.ts +++ b/src/db/runtime-tasks-rooms.ts @@ -28,6 +28,7 @@ import { assignRoomInDatabase, clearExplicitRoomModeInDatabase, deleteStoredRoomSettingsForTestsInDatabase, + deleteStoredRoomSkillOverrideFromDatabase, getAllRoomBindingsFromDatabase, getEffectiveRoomModeFromDatabase, getEffectiveRuntimeRoomModeFromDatabase, @@ -41,7 +42,9 @@ import { setRegisteredGroupForTestsInDatabase, setStoredRoomOwnerAgentTypeForTestsInDatabase, type StoredRoomSkillOverride, + type StoredRoomSkillOverrideInput, updateRegisteredGroupNameInDatabase, + upsertStoredRoomSkillOverrideInDatabase, } from './rooms.js'; import { type StoredRoomSettings } from './room-registration.js'; import { @@ -373,6 +376,18 @@ export function getStoredRoomSkillOverrides( return getStoredRoomSkillOverridesFromDatabase(db, chatJid); } +export function upsertStoredRoomSkillOverride( + input: StoredRoomSkillOverrideInput, +): void { + upsertStoredRoomSkillOverrideInDatabase(requireDatabase(), input); +} + +export function deleteStoredRoomSkillOverride( + input: Omit, +): void { + deleteStoredRoomSkillOverrideFromDatabase(requireDatabase(), input); +} + export function getExplicitRoomMode(chatJid: string): RoomMode | undefined { return getExplicitRoomModeFromDatabase(requireDatabase(), chatJid); } diff --git a/src/room-skill-settings.test.ts b/src/room-skill-settings.test.ts index 813b873..88cc6b5 100644 --- a/src/room-skill-settings.test.ts +++ b/src/room-skill-settings.test.ts @@ -3,7 +3,11 @@ import { Database } from 'bun:sqlite'; import { describe, expect, it } from 'vitest'; import { initializeDatabaseSchema } from './db/bootstrap.js'; -import { getStoredRoomSkillOverridesFromDatabase } from './db/rooms.js'; +import { + deleteStoredRoomSkillOverrideFromDatabase, + getStoredRoomSkillOverridesFromDatabase, + upsertStoredRoomSkillOverrideInDatabase, +} from './db/rooms.js'; import { buildRoomSkillSettingsSnapshot, type RoomSkillSettingsBuildInput, @@ -146,6 +150,39 @@ describe('room skill settings', () => { }); }); + it('keeps default mode when explicit enable rows do not change effective skills', () => { + const snapshot = buildRoomSkillSettingsSnapshot({ + 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', ['codex']]]), + overrides: [ + { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'runner-browser', + enabled: true, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }); + + expect(snapshot.rooms[0]?.agents[0]).toMatchObject({ + mode: 'all-enabled', + explicitEnabledSkillIds: ['runner:runner-browser'], + effectiveEnabledSkillIds: ['codex-user:browser', 'runner:runner-browser'], + }); + }); + it('reads normalized room skill overrides from the database', () => { const database = new Database(':memory:'); try { @@ -192,4 +229,58 @@ describe('room skill settings', () => { database.close(); } }); + + it('upserts and deletes room skill overrides in 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'); + + upsertStoredRoomSkillOverrideInDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: false, + }); + expect(getStoredRoomSkillOverridesFromDatabase(database)).toMatchObject([ + { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: false, + }, + ]); + + upsertStoredRoomSkillOverrideInDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + enabled: true, + }); + expect( + getStoredRoomSkillOverridesFromDatabase(database)[0], + ).toMatchObject({ + enabled: true, + }); + + deleteStoredRoomSkillOverrideFromDatabase(database, { + chatJid: 'room-1', + agentType: 'codex', + skillScope: 'runner', + skillName: 'agent-browser', + }); + expect(getStoredRoomSkillOverridesFromDatabase(database)).toEqual([]); + } finally { + database.close(); + } + }); }); diff --git a/src/room-skill-settings.ts b/src/room-skill-settings.ts index 448a064..ab073d7 100644 --- a/src/room-skill-settings.ts +++ b/src/room-skill-settings.ts @@ -1,9 +1,11 @@ import path from 'node:path'; import { + deleteStoredRoomSkillOverride, getAllRoomBindings, getRegisteredAgentTypesForJid, getStoredRoomSkillOverrides, + upsertStoredRoomSkillOverride, } from './db.js'; import { getRuntimeInventory, @@ -57,6 +59,23 @@ export interface RoomSkillSettingsBuildInput { overrides?: StoredRoomSkillOverride[]; } +export interface RoomSkillSettingUpdateInput { + roomJid: string; + agentType: string; + skillId: string; + enabled: boolean; +} + +export class RoomSkillSettingsError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = 'RoomSkillSettingsError'; + } +} + function skillId(scope: RoomSkillScope, name: string): string { return `${scope}:${name}`; } @@ -134,6 +153,26 @@ function overrideKey( return `${jid}\u0000${agentType}\u0000${scope}\u0000${name}`; } +function parseSkillId(id: string): { scope: RoomSkillScope; name: string } { + const separator = id.indexOf(':'); + if (separator <= 0 || separator === id.length - 1) { + throw new RoomSkillSettingsError('skillId must be ":"', 400); + } + const scope = id.slice(0, separator); + const name = id.slice(separator + 1); + if (scope !== 'codex-user' && scope !== 'claude-user' && scope !== 'runner') { + throw new RoomSkillSettingsError(`Unsupported skill scope: ${scope}`, 400); + } + return { scope, name }; +} + +function normalizeRoomSkillAgentType(agentType: string): AgentType { + if (agentType === 'codex' || agentType === 'claude-code') { + return agentType; + } + throw new RoomSkillSettingsError(`Unsupported agent type: ${agentType}`, 400); +} + function resolveRoomAgentTypes( jid: string, group: RegisteredGroup, @@ -202,7 +241,8 @@ export function buildRoomSkillSettingsSnapshot({ return { agentType, mode: - disabledSkillIds.length > 0 || explicitEnabledSkillIds.length > 0 + disabledSkillIds.length > 0 || + effectiveEnabledSkillIds.length !== available.length ? 'custom' : 'all-enabled', availableSkillIds: available.map((skill) => skill.id), @@ -237,3 +277,53 @@ export function getRoomSkillSettings(): RoomSkillSettingsSnapshot { overrides: getStoredRoomSkillOverrides(), }); } + +export function updateRoomSkillSetting( + input: RoomSkillSettingUpdateInput, +): RoomSkillSettingsSnapshot { + const roomJid = input.roomJid.trim(); + const agentType = normalizeRoomSkillAgentType(input.agentType); + const { scope, name } = parseSkillId(input.skillId.trim()); + const before = getRoomSkillSettings(); + const room = before.rooms.find((candidate) => candidate.jid === roomJid); + if (!room) { + throw new RoomSkillSettingsError('Room not found', 404); + } + const agent = room.agents.find( + (candidate) => candidate.agentType === agentType, + ); + if (!agent) { + throw new RoomSkillSettingsError( + 'Agent is not registered for this room', + 400, + ); + } + const skill = before.catalog.find( + (candidate) => candidate.scope === scope && candidate.name === name, + ); + if (!skill || !agent.availableSkillIds.includes(skill.id)) { + throw new RoomSkillSettingsError( + 'Skill is not available for this room agent', + 400, + ); + } + + if (input.enabled) { + deleteStoredRoomSkillOverride({ + chatJid: roomJid, + agentType, + skillScope: scope, + skillName: name, + }); + } else { + upsertStoredRoomSkillOverride({ + chatJid: roomJid, + agentType, + skillScope: scope, + skillName: name, + enabled: false, + }); + } + + return getRoomSkillSettings(); +} diff --git a/src/web-dashboard-settings-routes.test.ts b/src/web-dashboard-settings-routes.test.ts index f3ca628..f2b99dc 100644 --- a/src/web-dashboard-settings-routes.test.ts +++ b/src/web-dashboard-settings-routes.test.ts @@ -241,6 +241,7 @@ function makeDeps( updateFastMode: () => fastMode, updateModelConfig: () => modelConfig, updateMoaSettings: () => moaSettings, + updateRoomSkillSetting: () => roomSkillSettings, ...overrides, }; } @@ -336,6 +337,25 @@ describe('web dashboard settings routes', () => { calls.push(`moa:${JSON.stringify(input)}`); return moaSettings; }, + updateRoomSkillSetting: (input) => { + calls.push(`room-skill:${JSON.stringify(input)}`); + return { + ...roomSkillSettings, + rooms: [ + { + ...roomSkillSettings.rooms[0]!, + agents: [ + { + ...roomSkillSettings.rooms[0]!.agents[0]!, + mode: 'custom', + disabledSkillIds: [input.skillId], + effectiveEnabledSkillIds: [], + }, + ], + }, + ], + }; + }, }); const modelUpdate = await route( @@ -404,6 +424,31 @@ describe('web dashboard settings routes', () => { status: { model: 'glm', ok: true }, }); + const roomSkill = await route( + '/api/settings/room-skills', + 'PATCH', + { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + enabled: false, + }, + deps, + ); + expect(roomSkill?.status).toBe(200); + await expect(roomSkill?.json()).resolves.toMatchObject({ + rooms: [ + { + agents: [ + { + mode: 'custom', + disabledSkillIds: ['runner:agent-browser'], + }, + ], + }, + ], + }); + expect(calls).toEqual([ 'models:{"owner":{"model":"gpt-5.5"}}', 'add:claude-token', @@ -411,6 +456,42 @@ describe('web dashboard settings routes', () => { 'current:4', 'codex-features:{"goals":true}', 'moa:{"enabled":false,"models":[{"name":"kimi","enabled":false}]}', + 'room-skill:{"roomJid":"room@example","agentType":"codex","skillId":"runner:agent-browser","enabled":false}', ]); }); + + it('rejects unsupported room skill methods with 405', async () => { + const response = await route('/api/settings/room-skills', 'DELETE'); + expect(response?.status).toBe(405); + expect(response?.headers.get('allow')).toBe('GET, HEAD, PATCH, PUT'); + await expect(response?.json()).resolves.toEqual({ + error: 'Method not allowed', + }); + }); + + it('validates room skill mutation payloads', async () => { + const missingEnabled = await route('/api/settings/room-skills', 'PATCH', { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + }); + expect(missingEnabled?.status).toBe(400); + + const invalid = await route( + '/api/settings/room-skills', + 'PATCH', + { + roomJid: 'room@example', + agentType: 'codex', + skillId: 'runner:agent-browser', + enabled: false, + }, + makeDeps({ + updateRoomSkillSetting: () => { + throw new Error('boom'); + }, + }), + ); + expect(invalid?.status).toBe(500); + }); }); diff --git a/src/web-dashboard-settings-routes.ts b/src/web-dashboard-settings-routes.ts index 0468153..2d40652 100644 --- a/src/web-dashboard-settings-routes.ts +++ b/src/web-dashboard-settings-routes.ts @@ -25,7 +25,10 @@ import { } from './runtime-inventory.js'; import { getRoomSkillSettings, + RoomSkillSettingsError, + updateRoomSkillSetting, type RoomSkillSettingsSnapshot, + type RoomSkillSettingUpdateInput, } from './room-skill-settings.js'; import { checkMoaModel, @@ -60,6 +63,7 @@ export interface SettingsRouteDependencies { updateFastMode: typeof updateFastMode; updateModelConfig: typeof updateModelConfig; updateMoaSettings: typeof updateMoaSettings; + updateRoomSkillSetting: typeof updateRoomSkillSetting; } interface SettingsRouteContext { @@ -89,6 +93,7 @@ const defaultSettingsRouteDependencies: SettingsRouteDependencies = { updateFastMode, updateModelConfig, updateMoaSettings, + updateRoomSkillSetting, }; function readMethod(method: string): boolean { @@ -118,6 +123,19 @@ function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); } +function methodNotAllowed( + jsonResponse: JsonResponse, + allowed: string[], +): Response { + return jsonResponse( + { error: 'Method not allowed' }, + { + status: 405, + headers: { Allow: allowed.join(', ') }, + }, + ); +} + async function handleModelSettingsRoute( request: Request, jsonResponse: JsonResponse, @@ -228,13 +246,42 @@ function handleRuntimeInventoryRoute( return jsonResponse(deps.getRuntimeInventory()); } -function handleRoomSkillsRoute( +async function handleRoomSkillsRoute( request: Request, jsonResponse: JsonResponse, deps: SettingsRouteDependencies, -): Response | null { - if (!readMethod(request.method)) return null; - return jsonResponse(deps.getRoomSkillSettings()); +): Promise { + if (readMethod(request.method)) { + return jsonResponse(deps.getRoomSkillSettings()); + } + if (request.method !== 'PATCH' && request.method !== 'PUT') { + return methodNotAllowed(jsonResponse, ['GET', 'HEAD', 'PATCH', 'PUT']); + } + + const body = await readJsonObject(request, jsonResponse); + if (body instanceof Response) return body; + + const input: RoomSkillSettingUpdateInput = { + roomJid: typeof body.roomJid === 'string' ? body.roomJid : '', + agentType: typeof body.agentType === 'string' ? body.agentType : '', + skillId: typeof body.skillId === 'string' ? body.skillId : '', + enabled: body.enabled === true, + }; + if (typeof body.enabled !== 'boolean') { + return jsonResponse( + { error: 'enabled must be a boolean' }, + { status: 400 }, + ); + } + + try { + return jsonResponse(deps.updateRoomSkillSetting(input)); + } catch (err) { + if (err instanceof RoomSkillSettingsError) { + return jsonResponse({ error: err.message }, { status: err.status }); + } + return jsonResponse({ error: errorMessage(err) }, { status: 500 }); + } } async function handleClaudeAccountAddRoute( From 4fcced66f6d1bf65ca60fb6163ee742a377eaf3f Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 16:10:34 +0900 Subject: [PATCH 22/39] Apply room skill overrides to runner spawn --- src/agent-runner-environment.test.ts | 136 +++++++++++++++++++ src/agent-runner-environment.ts | 188 +++++++++++++++++++++++---- src/agent-runner.test.ts | 56 ++++++++ src/agent-runner.ts | 27 +++- 4 files changed, 384 insertions(+), 23 deletions(-) diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index f972a02..4e8557d 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -108,6 +108,15 @@ const group: RegisteredGroup = { agentType: 'codex', }; +function writeSkill(dir: string, name: string): void { + const skillDir = path.join(dir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${name} skill\n---\n`, + ); +} + describe('prepareGroupEnvironment codex auth handling', () => { let tempRoot: string; let previousCwd: string; @@ -370,6 +379,133 @@ describe('prepareGroupEnvironment codex auth handling', () => { }); }); +describe('prepareGroupEnvironment room skill overrides', () => { + let tempRoot: string; + let previousCwd: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join('/tmp', 'ejclaw-agent-skills-')); + previousCwd = process.cwd(); + process.chdir(tempRoot); + process.env.EJ_TEST_ROOT = tempRoot; + process.env.EJ_TEST_HOME = path.join(tempRoot, 'home'); + fs.mkdirSync(path.join(process.env.EJ_TEST_HOME, '.codex'), { + recursive: true, + }); + mockReadEnvFile.mockReset(); + mockGetActiveCodexAuthPath.mockReset(); + }); + + afterEach(() => { + process.chdir(previousCwd); + delete process.env.EJ_TEST_ROOT; + delete process.env.EJ_TEST_HOME; + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('filters Claude session skills with room overrides at spawn time', () => { + mockReadEnvFile.mockReturnValue({}); + const homeSkills = path.join( + process.env.EJ_TEST_HOME!, + '.claude', + 'skills', + ); + const workDir = path.join(tempRoot, 'workdir'); + const runnerSkills = path.join(tempRoot, 'runners', 'skills'); + writeSkill(homeSkills, 'claude-keep'); + writeSkill(path.join(workDir, '.claude', 'skills'), 'workdir-keep'); + writeSkill(runnerSkills, 'runner-keep'); + writeSkill(runnerSkills, 'runner-off'); + + prepareGroupEnvironment( + { ...group, agentType: 'claude-code', workDir }, + false, + 'dc:test', + { + skillOverrides: [ + { + chatJid: 'dc:test', + agentType: 'claude-code', + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }, + ); + + const sessionSkills = path.join( + tempRoot, + 'sessions', + group.folder, + 'services', + 'codex-main', + '.claude', + 'skills', + ); + expect(fs.existsSync(path.join(sessionSkills, 'claude-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'workdir-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false); + }); + + it('uses a session-scoped Codex home when room overrides disable skills', () => { + mockReadEnvFile.mockReturnValue({}); + const codexSkills = path.join( + process.env.EJ_TEST_HOME!, + '.agents', + 'skills', + ); + const runnerSkills = path.join(tempRoot, 'runners', 'skills'); + writeSkill(codexSkills, 'codex-keep'); + writeSkill(codexSkills, 'codex-off'); + writeSkill(runnerSkills, 'runner-keep'); + writeSkill(runnerSkills, 'runner-off'); + + const prepared = prepareGroupEnvironment(group, false, 'dc:test', { + skillOverrides: [ + { + chatJid: 'dc:test', + agentType: 'codex', + skillScope: 'codex-user', + skillName: 'codex-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + { + chatJid: 'dc:test', + agentType: 'codex', + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }, + ], + }); + + const sessionRoot = path.join( + tempRoot, + 'sessions', + group.folder, + 'services', + 'codex-main', + ); + const sessionHome = path.join(sessionRoot, 'home'); + const sessionSkills = path.join(sessionHome, '.agents', 'skills'); + expect(prepared.env.HOME).toBe(sessionHome); + expect(prepared.env.CODEX_HOME).toBe(path.join(sessionRoot, '.codex')); + expect(fs.existsSync(path.join(sessionSkills, 'codex-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'runner-keep'))).toBe(true); + expect(fs.existsSync(path.join(sessionSkills, 'codex-off'))).toBe(false); + expect(fs.existsSync(path.join(sessionSkills, 'runner-off'))).toBe(false); + expect(fs.existsSync(path.join(codexSkills, 'codex-off'))).toBe(true); + }); +}); + describe('prepareGroupEnvironment Codex goals handling', () => { let tempRoot: string; let previousCwd: string; diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 433b62f..1d779be 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -33,6 +33,7 @@ import { hasReviewerLease, } from './service-routing.js'; import type { AgentType, RegisteredGroup } from './types.js'; +import type { StoredRoomSkillOverride } from './db/rooms.js'; // writeCodexApiKeyAuth removed — Codex uses OAuth only. // API key auth caused unintended billing. @@ -53,6 +54,72 @@ function syncDirectoryEntries(sources: string[], destination: string): void { } } +type SkillSyncScope = 'codex-user' | 'claude-user' | 'runner' | 'workdir'; + +interface SkillSyncSource { + dir: string; + scope: SkillSyncScope; +} + +function getDisabledSkillNamesByScope( + overrides: StoredRoomSkillOverride[] | undefined, + agentType: AgentType, +): Map> { + const disabled = new Map>(); + for (const override of overrides ?? []) { + if (override.agentType !== agentType || override.enabled !== false) + continue; + if ( + override.skillScope !== 'codex-user' && + override.skillScope !== 'claude-user' && + override.skillScope !== 'runner' + ) { + continue; + } + const names = disabled.get(override.skillScope) ?? new Set(); + names.add(override.skillName); + disabled.set(override.skillScope, names); + } + return disabled; +} + +function hasDisabledSkillOverrides( + overrides: StoredRoomSkillOverride[] | undefined, + agentType: AgentType, +): boolean { + return (overrides ?? []).some( + (override) => + override.agentType === agentType && override.enabled === false, + ); +} + +function syncRoomSkillDirectories(args: { + sources: SkillSyncSource[]; + destination: string; + agentType: AgentType; + overrides?: StoredRoomSkillOverride[]; +}): void { + const disabledNamesByScope = getDisabledSkillNamesByScope( + args.overrides, + args.agentType, + ); + + fs.rmSync(args.destination, { recursive: true, force: true }); + fs.mkdirSync(args.destination, { recursive: true }); + + for (const source of args.sources) { + if (!fs.existsSync(source.dir)) continue; + for (const entry of fs.readdirSync(source.dir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + if (disabledNamesByScope.get(source.scope)?.has(entry.name)) continue; + + const srcPath = path.join(source.dir, entry.name); + const dstPath = path.join(args.destination, entry.name); + fs.cpSync(srcPath, dstPath, { recursive: true }); + } + } +} + function readOptionalPromptFile( projectRoot: string, filename: string, @@ -301,6 +368,7 @@ function prepareCodexSessionEnvironment(args: { isPairedRoom: boolean; useFailoverPromptPack: boolean; memoryBriefing?: string; + skillOverrides?: StoredRoomSkillOverride[]; }): void { // API key auth intentionally removed — Codex uses OAuth only. // Never pass any API key to Codex child process to prevent API billing. @@ -385,15 +453,39 @@ function prepareCodexSessionEnvironment(args: { // Codex reads skills from ~/.agents/skills/ (user-level) and // {workDir}/.agents/skills/ (project-level), NOT from .codex/skills/. - // Sync to the user-level path so all Codex sessions can discover them. - const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills'); - syncDirectoryEntries( - [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(args.projectRoot, 'runners', 'skills'), - ], - codexSkillsDir, - ); + if (hasDisabledSkillOverrides(args.skillOverrides, 'codex')) { + const sessionHomeDir = path.join(args.sessionRootDir, 'home'); + args.env.HOME = sessionHomeDir; + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(os.homedir(), '.agents', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(args.projectRoot, 'runners', 'skills'), + scope: 'runner', + }, + ], + destination: path.join(sessionHomeDir, '.agents', 'skills'), + agentType: 'codex', + overrides: args.skillOverrides, + }); + } else { + // Preserve the historical global sync path when no room override exists. + const codexSkillsDir = path.join(os.homedir(), '.agents', 'skills'); + syncDirectoryEntries( + [ + path.join(os.homedir(), '.claude', 'skills'), + path.join(args.projectRoot, 'runners', 'skills'), + ], + codexSkillsDir, + ); + } const mcpServerPath = path.join( args.projectRoot, @@ -411,7 +503,7 @@ function prepareCodexSessionEnvironment(args: { chatJid: args.chatJid, groupFolder: args.group.folder, isMain: args.isMain, - agentType: args.group.agentType || 'claude-code', + agentType: 'codex', workDir: args.env.EJCLAW_WORK_DIR || args.group.workDir || args.projectRoot, }); @@ -430,6 +522,10 @@ export interface PreparedGroupEnvironment { runnerDir: string; } +export interface PreparedReadonlySessionEnvironment { + codexHomeDir?: string; +} + export function prepareGroupEnvironment( group: RegisteredGroup, isMain: boolean, @@ -438,6 +534,7 @@ export function prepareGroupEnvironment( memoryBriefing?: string; runtimeTaskId?: string; useTaskScopedSession?: boolean; + skillOverrides?: StoredRoomSkillOverride[]; }, ): PreparedGroupEnvironment { const projectRoot = process.cwd(); @@ -463,12 +560,26 @@ export function prepareGroupEnvironment( const workDirClaude = group.workDir ? path.join(group.workDir, '.claude') : null; - const skillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - ...(workDirClaude ? [path.join(workDirClaude, 'skills')] : []), - path.join(projectRoot, 'runners', 'skills'), - ]; - syncDirectoryEntries(skillSources, path.join(groupSessionsDir, 'skills')); + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'claude-user', + }, + ...(workDirClaude + ? [ + { + dir: path.join(workDirClaude, 'skills'), + scope: 'workdir' as const, + }, + ] + : []), + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(groupSessionsDir, 'skills'), + agentType: 'claude-code', + overrides: options?.skillOverrides, + }); const groupIpcDir = runtimeTaskId ? resolveTaskRuntimeIpcPath(group.folder, runtimeTaskId) @@ -566,6 +677,7 @@ export function prepareGroupEnvironment( isPairedRoom, useFailoverPromptPack: useCodexReviewFailoverPromptPack, memoryBriefing: options?.memoryBriefing, + skillOverrides: options?.skillOverrides, }); } else { prepareClaudeEnvironment({ env, envVars, group }); @@ -594,7 +706,8 @@ export function prepareReadonlySessionEnvironment(args: { ipcDir?: string; hostIpcDir?: string; workDir?: string; -}): void { + skillOverrides?: StoredRoomSkillOverride[]; +}): PreparedReadonlySessionEnvironment { const { sessionDir, chatJid, @@ -606,6 +719,7 @@ export function prepareReadonlySessionEnvironment(args: { ipcDir = '/workspace/ipc', hostIpcDir = ipcDir, workDir = '/workspace/project', + skillOverrides, } = args; const projectRoot = process.cwd(); @@ -614,11 +728,40 @@ export function prepareReadonlySessionEnvironment(args: { ensureClaudeGlobalSettingsFile(sessionDir); // Sync skills from host - const skillSources = [ - path.join(os.homedir(), '.claude', 'skills'), - path.join(projectRoot, 'runners', 'skills'), - ]; - syncDirectoryEntries(skillSources, path.join(sessionDir, 'skills')); + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: agentType === 'codex' ? 'codex-user' : 'claude-user', + }, + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(sessionDir, 'skills'), + agentType, + overrides: skillOverrides, + }); + const codexHomeDir = + agentType === 'codex' && hasDisabledSkillOverrides(skillOverrides, 'codex') + ? sessionDir + : undefined; + if (codexHomeDir) { + syncRoomSkillDirectories({ + sources: [ + { + dir: path.join(os.homedir(), '.claude', 'skills'), + scope: 'codex-user', + }, + { + dir: path.join(os.homedir(), '.agents', 'skills'), + scope: 'codex-user', + }, + { dir: path.join(projectRoot, 'runners', 'skills'), scope: 'runner' }, + ], + destination: path.join(codexHomeDir, '.agents', 'skills'), + agentType, + overrides: skillOverrides, + }); + } // Build CLAUDE.md with role-appropriate prompts (reviewer or arbiter) const claudePlatformPrompt = readPlatformPrompt('claude-code', projectRoot); @@ -688,4 +831,5 @@ export function prepareReadonlySessionEnvironment(args: { } else if (fs.existsSync(sessionClaudeMdPath)) { fs.unlinkSync(sessionClaudeMdPath); } + return { codexHomeDir }; } diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index 962c85e..8428462 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -71,6 +71,10 @@ vi.mock('./service-routing.js', () => ({ hasReviewerLease: vi.fn(() => true), })); +vi.mock('./db.js', () => ({ + getStoredRoomSkillOverrides: vi.fn(() => []), +})); + // Create a controllable fake ChildProcess function createFakeProcess() { const proc = new EventEmitter() as EventEmitter & { @@ -108,6 +112,7 @@ vi.mock('child_process', async () => { import { runAgentProcess, AgentOutput } from './agent-runner.js'; import * as agentRunnerEnvironment from './agent-runner-environment.js'; +import { getStoredRoomSkillOverrides } from './db.js'; import type { RegisteredGroup } from './types.js'; const testGroup: RegisteredGroup = { @@ -681,10 +686,61 @@ OUROBOROS_LLM_BACKEND = "codex" }); }); +describe('agent-runner room skill overrides', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]); + fakeProc = createFakeProcess(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('passes room skill overrides into runner environment setup', async () => { + const skillOverride = { + chatJid: testInput.chatJid, + agentType: 'claude-code' as const, + skillScope: 'runner', + skillName: 'runner-off', + enabled: false, + createdAt: '2026-05-04T00:00:00.000Z', + updatedAt: '2026-05-04T00:00:00.000Z', + }; + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([skillOverride]); + const prepareGroupEnvironmentSpy = vi.spyOn( + agentRunnerEnvironment, + 'prepareGroupEnvironment', + ); + + const resultPromise = runAgentProcess( + testGroup, + testInput, + () => {}, + async () => {}, + ); + + fakeProc.emit('close', 0); + const result = await resultPromise; + expect(result.status).toBe('success'); + expect(getStoredRoomSkillOverrides).toHaveBeenCalledWith(testInput.chatJid); + expect(prepareGroupEnvironmentSpy).toHaveBeenCalledWith( + testGroup, + testInput.isMain, + testInput.chatJid, + expect.objectContaining({ + skillOverrides: [skillOverride], + }), + ); + }); +}); + describe('agent-runner Codex goals', () => { beforeEach(() => { vi.useRealTimers(); vi.clearAllMocks(); + vi.mocked(getStoredRoomSkillOverrides).mockReturnValue([]); fakeProc = createFakeProcess(); vi.mocked(fs.existsSync).mockImplementation((p: fs.PathLike) => { const str = String(p); diff --git a/src/agent-runner.ts b/src/agent-runner.ts index e64c239..f1b9a84 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -13,6 +13,7 @@ import { prepareGroupEnvironment, } from './agent-runner-environment.js'; import { runSpawnedAgentProcess } from './agent-runner-process.js'; +import { getStoredRoomSkillOverrides } from './db.js'; export { type AvailableGroup, writeGroupsSnapshot, @@ -20,6 +21,7 @@ export { } from './agent-runner-snapshot.js'; import { logger } from './logger.js'; import { RegisteredGroup, RoomRoleContext } from './types.js'; +import type { StoredRoomSkillOverride } from './db/rooms.js'; export interface AgentInput { prompt: string; @@ -50,6 +52,23 @@ export interface AgentOutput { error?: string; } +function readRoomSkillOverridesForRunner( + chatJid: string, +): StoredRoomSkillOverride[] { + try { + return getStoredRoomSkillOverrides(chatJid); + } catch (err) { + logger.warn( + { + err, + chatJid, + }, + 'Failed to read room skill overrides; falling back to default skills', + ); + return []; + } +} + export async function runAgentProcess( group: RegisteredGroup, input: AgentInput, @@ -65,6 +84,7 @@ export async function runAgentProcess( // ── Host process mode (owner) ─────────────────────────────────── const startTime = Date.now(); + const skillOverrides = readRoomSkillOverridesForRunner(input.chatJid); const { env, groupDir, runnerDir } = prepareGroupEnvironment( group, input.isMain, @@ -73,6 +93,7 @@ export async function runAgentProcess( memoryBriefing: input.memoryBriefing, runtimeTaskId: input.runtimeTaskId, useTaskScopedSession: input.useTaskScopedSession, + skillOverrides, }, ); @@ -88,7 +109,7 @@ export async function runAgentProcess( (input.roomRoleContext?.role === 'reviewer' || input.roomRoleContext?.role === 'arbiter') ) { - prepareReadonlySessionEnvironment({ + const readonlySession = prepareReadonlySessionEnvironment({ sessionDir: envOverrides.CLAUDE_CONFIG_DIR, chatJid: input.chatJid, isMain: input.isMain, @@ -99,9 +120,13 @@ export async function runAgentProcess( ipcDir: env.EJCLAW_IPC_DIR, hostIpcDir: env.EJCLAW_HOST_IPC_DIR, workDir: envOverrides.EJCLAW_WORK_DIR || env.EJCLAW_WORK_DIR, + skillOverrides, }); if ((group.agentType || 'claude-code') === 'codex') { env.CODEX_HOME = path.join(envOverrides.CLAUDE_CONFIG_DIR, '.codex'); + if (readonlySession.codexHomeDir) { + env.HOME = readonlySession.codexHomeDir; + } } } if (input.runId) { From bc03d66226f03658f5df7ebbbca608d217e270b3 Mon Sep 17 00:00:00 2001 From: ejclaw Date: Mon, 4 May 2026 16:35:14 +0900 Subject: [PATCH 23/39] Refine scheduled task dashboard UX --- apps/dashboard/src/TaskPanel.css | 688 +++++++++++++++++++++++++++ apps/dashboard/src/TaskPanel.test.ts | 3 + apps/dashboard/src/TaskPanel.tsx | 284 ++++++++--- apps/dashboard/src/i18n.ts | 44 +- apps/dashboard/src/styles.css | 455 ------------------ scripts/dashboard-ux.ts | 73 ++- 6 files changed, 1000 insertions(+), 547 deletions(-) create mode 100644 apps/dashboard/src/TaskPanel.css diff --git a/apps/dashboard/src/TaskPanel.css b/apps/dashboard/src/TaskPanel.css new file mode 100644 index 0000000..01ffdd4 --- /dev/null +++ b/apps/dashboard/src/TaskPanel.css @@ -0,0 +1,688 @@ +.task-board { + display: grid; + gap: 14px; +} + +.task-command-center { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(210px, 0.75fr) minmax( + 260px, + 1fr + ); + gap: 12px; + align-items: stretch; + padding: 16px; + border: 1px solid var(--panel-border); + border-radius: 26px; + background: + radial-gradient(circle at 12% 0%, rgba(191, 95, 44, 0.18), transparent 34%), + linear-gradient( + 135deg, + rgba(255, 255, 255, 0.065), + rgba(255, 255, 255, 0.025) + ); + box-shadow: 0 18px 48px rgba(16, 10, 6, 0.08); +} + +.task-command-copy, +.task-command-next, +.task-summary-metric { + min-width: 0; +} + +.task-command-copy strong { + display: block; + margin-top: 4px; + font-size: clamp(23px, 4vw, 34px); + line-height: 1; + letter-spacing: -0.06em; +} + +.task-command-copy p { + max-width: 520px; + margin: 9px 0 0; + overflow: hidden; + color: var(--muted); + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-command-next { + display: grid; + gap: 3px; + align-content: center; + padding: 12px; + border: 1px solid rgba(191, 95, 44, 0.22); + border-radius: 18px; + background: rgba(191, 95, 44, 0.08); +} + +.task-command-next small, +.task-command-next span, +.task-summary-metric small { + color: var(--muted); + font-size: 11px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.task-command-next strong { + overflow: hidden; + font-size: 21px; + letter-spacing: -0.04em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-command-next span { + overflow: hidden; + text-transform: none; + letter-spacing: 0; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-command-metrics { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.task-summary-metric { + display: grid; + gap: 4px; + align-content: center; + min-height: 70px; + padding: 10px 12px; + border: 1px solid var(--panel-border); + border-radius: 18px; + background: rgba(255, 255, 255, 0.035); +} + +.task-summary-metric strong { + font-size: 24px; + line-height: 1; + letter-spacing: -0.05em; +} + +.task-create-form { + display: grid; + grid-template-columns: minmax(180px, 0.42fr) minmax(0, 1fr); + gap: 12px; + align-items: stretch; + padding: 12px; + border: 1px solid var(--panel-border); + border-radius: 24px; + background: rgba(255, 255, 255, 0.04); +} + +.task-edit-form { + display: grid; + grid-template-columns: + minmax(0, 1.4fr) minmax(110px, 0.6fr) minmax(0, 1fr) + auto; + gap: 8px; + align-items: end; + margin-top: 8px; + padding: 9px; + border: 1px solid var(--panel-border); + border-radius: 16px; + background: rgba(255, 255, 255, 0.04); +} + +.task-create-head { + display: grid; + align-content: center; + padding: 14px; + border: 1px solid rgba(191, 95, 44, 0.2); + border-radius: 20px; + background: + linear-gradient(145deg, rgba(191, 95, 44, 0.14), transparent), + rgba(255, 255, 255, 0.035); +} + +.task-create-head strong { + display: block; + margin-top: 5px; + font-size: 22px; + line-height: 1.06; + letter-spacing: -0.05em; +} + +.task-create-head p { + margin: 10px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.task-create-body { + display: grid; + gap: 10px; +} + +.task-form-controls { + display: grid; + grid-template-columns: minmax(160px, 1.2fr) repeat(3, minmax(110px, 1fr)); + gap: 8px; +} + +.task-form-footer { + display: flex; + gap: 10px; + align-items: center; + justify-content: space-between; +} + +.task-form-footer > span { + color: var(--muted); + font-size: 12px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.task-create-form label, +.task-edit-form label { + display: grid; + min-width: 0; + gap: 4px; + color: var(--muted); + font-size: 11px; + font-weight: 700; +} + +.task-create-form input, +.task-create-form select, +.task-create-form textarea, +.task-edit-form input, +.task-edit-form select, +.task-edit-form textarea { + width: 100%; + min-height: 44px; + border: 1px solid var(--panel-border-strong); + border-radius: 15px; + padding: 10px 12px; + color: var(--ink); + background: rgba(255, 255, 255, 0.04); + font: inherit; + font-size: 13px; +} + +.task-create-form textarea, +.task-edit-form textarea { + min-height: 82px; + resize: vertical; +} + +.task-edit-form textarea { + min-height: 44px; +} + +.task-create-form input:focus-visible, +.task-create-form select:focus-visible, +.task-create-form textarea:focus-visible, +.task-edit-form input:focus-visible, +.task-edit-form select:focus-visible, +.task-edit-form textarea:focus-visible { + outline: 3px solid rgba(191, 95, 44, 0.18); +} + +.task-create-form button, +.task-edit-form button { + min-height: 44px; + border: 0; + border-radius: 999px; + padding: 0 18px; + color: var(--bg); + background: var(--accent); + font: inherit; + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.task-create-form button:disabled, +.task-edit-form button:disabled { + cursor: progress; + opacity: 0.55; +} + +.task-form-wide { + grid-column: span 2; +} + +.task-create-form .task-form-wide { + grid-column: auto; +} + +.task-lanes { + display: grid; + gap: 12px; +} + +.task-group { + display: grid; + gap: 12px; + padding: 14px; + border: 1px solid var(--panel-border); + border-radius: 24px; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent), + rgba(255, 255, 255, 0.018); +} + +.task-group-completed { + display: block; +} + +.task-group-completed[open] { + display: grid; +} + +.task-group-completed summary { + cursor: pointer; + list-style: none; +} + +.task-group-completed summary::-webkit-details-marker { + display: none; +} + +.task-group-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: center; +} + +.task-group-head strong { + display: block; + margin-top: 2px; + font-size: 18px; + letter-spacing: -0.03em; +} + +.task-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; +} + +.task-card { + display: grid; + gap: 10px; + min-width: 0; + padding: 15px; + border: 1px solid var(--panel-border); + border-radius: 20px; + background: + linear-gradient(160deg, rgba(255, 255, 255, 0.055), transparent 58%), + var(--bg-card); + box-shadow: 0 14px 34px rgba(15, 10, 7, 0.05); +} + +.task-card-main, +.task-time-grid, +.task-suspended, +.task-result { + min-width: 0; +} + +.task-card-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} + +.task-title { + display: grid; + gap: 5px; + min-width: 0; +} + +.task-kind { + color: var(--accent-strong); + font-size: 11px; + font-weight: 900; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.task-title strong { + display: block; + overflow: hidden; + font-size: 16px; + letter-spacing: -0.02em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-meta-strip { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.task-meta-strip span { + display: inline-flex; + max-width: 100%; + min-height: 26px; + align-items: center; + padding: 0 9px; + overflow: hidden; + border-radius: 999px; + color: var(--muted); + font-size: 11px; + font-weight: 800; + background: rgba(255, 255, 255, 0.05); + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-status-line { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: flex-end; +} + +.task-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.task-action { + min-height: 44px; + padding: 0 14px; + border: 1px solid var(--panel-border-strong); + border-radius: 999px; + color: var(--ink); + font: inherit; + font-size: 12px; + font-weight: 800; + background: rgba(255, 255, 255, 0.04); + cursor: pointer; +} + +.task-action:hover, +.task-action:focus-visible { + border-color: rgba(191, 95, 44, 0.42); + outline: none; + box-shadow: 0 0 0 3px rgba(191, 95, 44, 0.12); +} + +.task-action:disabled { + cursor: progress; + opacity: 0.55; +} + +.task-action.is-busy { + display: inline-flex; + gap: 7px; + align-items: center; + justify-content: center; + cursor: progress; + opacity: 0.85; + background: rgba(191, 95, 44, 0.12); + border-color: rgba(191, 95, 44, 0.32); +} + +.task-action.is-busy::before { + content: ''; + display: block; + width: 12px; + height: 12px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: ej-spin 0.7s linear infinite; +} + +@keyframes ej-spin { + to { + transform: rotate(360deg); + } +} + +.task-action-cancel { + color: #8f2f22; + border-color: rgba(183, 71, 52, 0.22); + background: rgba(183, 71, 52, 0.08); +} + +.task-time-grid { + display: grid; + grid-template-columns: minmax(150px, 1.2fr) repeat(2, minmax(0, 0.9fr)); + gap: 8px; +} + +.task-time-grid > span, +.task-suspended, +.task-result { + padding: 9px; + border: 1px solid var(--panel-border); + border-radius: 14px; + background: rgba(255, 255, 255, 0.025); +} + +.task-next-run { + border-color: rgba(191, 95, 44, 0.25) !important; + background: rgba(191, 95, 44, 0.08) !important; +} + +.task-time-grid small, +.task-suspended span, +.task-result span, +.task-time-grid em, +.task-suspended em { + color: var(--muted); + font-size: 11px; + font-style: normal; + font-weight: 600; +} + +.task-time-grid small, +.task-time-grid strong, +.task-time-grid em, +.task-suspended span, +.task-suspended strong, +.task-suspended em, +.task-result span, +.task-result strong { + display: block; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-time-grid strong, +.task-suspended strong, +.task-result strong { + margin-top: 3px; +} + +.task-next-run strong { + font-size: 18px; + letter-spacing: -0.03em; +} + +.task-suspended { + border-color: rgba(181, 138, 34, 0.22); + background: rgba(181, 138, 34, 0.08); +} + +.task-result { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; + border-color: rgba(63, 127, 81, 0.15); +} + +.result-fail { + border-color: rgba(183, 71, 52, 0.28); + background: rgba(183, 71, 52, 0.08); +} + +.result-none { + border-color: rgba(109, 115, 95, 0.14); +} + +.task-prompt { + color: var(--muted); +} + +.task-edit summary, +.task-prompt summary { + min-height: 32px; + color: var(--accent-strong); + font-size: 12px; + font-weight: 800; + cursor: pointer; +} + +.task-prompt p { + margin: 4px 0; + overflow-wrap: anywhere; +} + +.task-prompt small { + display: block; + overflow: hidden; + font-family: + 'SF Mono', 'Cascadia Code', 'Roboto Mono', ui-monospace, monospace; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.task-group-empty { + padding: 12px; + border-radius: 14px; + color: var(--muted); + background: rgba(255, 255, 255, 0.025); +} + +.pill-watchers { + color: #173e23; + background: rgba(63, 127, 81, 0.18); +} + +.pill-scheduled { + color: #6e4a05; + background: rgba(181, 138, 34, 0.16); +} + +.pill-paused { + color: #6e4a05; + background: rgba(181, 138, 34, 0.2); +} + +.pill-completed { + color: #625d52; + background: rgba(109, 115, 95, 0.16); +} + +.task-card > p { + overflow-wrap: anywhere; +} + +@media (max-width: 760px) { + .task-command-center, + .task-create-form, + .task-form-controls, + .task-card-main { + grid-template-columns: 1fr; + } + + .task-command-center { + padding: 12px; + } + + .task-command-metrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .task-summary-metric { + min-height: 62px; + } + + .task-create-head { + padding: 12px; + } + + .task-create-head strong { + font-size: 19px; + } + + .task-form-footer { + display: grid; + } + + .task-form-footer button { + width: 100%; + } + + .task-group { + padding: 10px; + } + + .task-edit-form { + grid-template-columns: 1fr; + gap: 7px; + padding: 10px; + } + + .task-form-wide { + grid-column: auto; + } + + .task-list { + grid-template-columns: 1fr; + gap: 8px; + } + + .task-card { + gap: 7px; + padding: 9px; + } + + .task-action { + min-height: 44px; + padding-inline: 12px; + } + + .task-card-main { + gap: 7px; + } + + .task-time-grid { + grid-template-columns: 1fr; + gap: 6px; + } + + .task-time-grid > span, + .task-suspended, + .task-result { + padding: 7px; + } + + .task-prompt summary { + min-height: 44px; + } +} + +@media (max-width: 380px) { + .task-command-metrics { + grid-template-columns: 1fr; + } + + .task-time-grid { + grid-template-columns: 1fr; + } +} diff --git a/apps/dashboard/src/TaskPanel.test.ts b/apps/dashboard/src/TaskPanel.test.ts index 27a17f9..a10ed15 100644 --- a/apps/dashboard/src/TaskPanel.test.ts +++ b/apps/dashboard/src/TaskPanel.test.ts @@ -46,6 +46,9 @@ describe('TaskPanel', () => { expect(html).toContain(t.tasks.groups.scheduled); expect(html).toContain('eyejokerdb-main'); + expect(html).toContain(t.panels.scheduled); + expect(html).toContain(t.tasks.next); + expect(html).toContain('10m'); expect(html).toContain(t.tasks.actions.pause); expect(html).toContain(t.tasks.actions.cancel); expect(html).toContain('Task completed'); diff --git a/apps/dashboard/src/TaskPanel.tsx b/apps/dashboard/src/TaskPanel.tsx index c197c48..91ec520 100644 --- a/apps/dashboard/src/TaskPanel.tsx +++ b/apps/dashboard/src/TaskPanel.tsx @@ -13,6 +13,7 @@ import { localeTags, type Locale, type Messages } from './i18n'; import { redactSecretsForPreview } from './redaction'; import { statusLabel } from './dashboardHelpers'; import { TaskActionButtons, type TaskActionKey } from './TaskActionButtons'; +import './TaskPanel.css'; export type { TaskActionKey } from './TaskActionButtons'; @@ -41,6 +42,15 @@ interface TaskGroup { tasks: DashboardTask[]; } +interface TaskSummary { + completed: number; + nextTask: DashboardTask | null; + paused: number; + scheduled: number; + total: number; + watchers: number; +} + interface TaskCreateFormProps { rooms: RoomOption[]; onTaskCreate: (input: CreateScheduledTaskInput) => void; @@ -170,6 +180,23 @@ function taskDisplayName(task: DashboardTask, t: Messages): string { return task.id; } +function taskHeadline(task: DashboardTask, t: Messages): string { + if (task.isWatcher && task.ciProvider) { + return `${t.tasks.ciWatch} · ${task.ciProvider}`; + } + return safePreview(task.promptPreview, taskDisplayName(task, t)); +} + +function taskScheduleText(task: DashboardTask, t: Messages): string { + const scheduleType = + task.scheduleType === 'cron' || + task.scheduleType === 'interval' || + task.scheduleType === 'once' + ? t.tasks.scheduleTypes[task.scheduleType] + : task.scheduleType; + return `${scheduleType} · ${task.scheduleValue || '-'}`; +} + function isTaskScheduleType( value: FormDataEntryValue | null, ): value is DashboardTaskScheduleType { @@ -259,6 +286,102 @@ function buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] { ]; } +function buildTaskSummary(tasks: DashboardTask[]): TaskSummary { + const summary: TaskSummary = { + completed: 0, + nextTask: null, + paused: 0, + scheduled: 0, + total: tasks.length, + watchers: 0, + }; + + for (const task of tasks) { + const group = taskGroupKey(task); + if (group === 'watchers') summary.watchers += 1; + if (group === 'scheduled') summary.scheduled += 1; + if (group === 'paused') summary.paused += 1; + if (group === 'completed') summary.completed += 1; + + if (task.status !== 'active' || !task.nextRun) continue; + const nextTime = new Date(task.nextRun).getTime(); + if (Number.isNaN(nextTime)) continue; + const currentTime = summary.nextTask?.nextRun + ? new Date(summary.nextTask.nextRun).getTime() + : Number.POSITIVE_INFINITY; + if (nextTime < currentTime) { + summary.nextTask = task; + } + } + + return summary; +} + +function TaskSummaryMetric({ label, value }: { label: string; value: number }) { + return ( + + {value} + {label} + + ); +} + +function TaskBoardSummary({ + locale, + summary, + t, +}: { + locale: Locale; + summary: TaskSummary; + t: Messages; +}) { + const nextTask = summary.nextTask; + return ( +
    +
    + {t.panels.scheduled} + + {summary.total} {t.tasks.count} + +

    + {nextTask + ? `${t.tasks.next}: ${taskHeadline(nextTask, t)}` + : t.tasks.empty} +

    +
    +
    + {t.tasks.next} + + {nextTask ? formatRelativeDate(nextTask.nextRun, locale, t) : '-'} + + + {nextTask + ? `${nextTask.groupFolder} · ${taskScheduleText(nextTask, t)}` + : t.tasks.noTime} + +
    +
    + + + + +
    +
    + ); +} + function TaskCreateForm({ rooms, onTaskCreate, @@ -280,54 +403,62 @@ function TaskCreateForm({
    {t.tasks.createTitle} {t.tasks.createSubtitle} +

    {t.tasks.scheduleValueHint}

    +
    +
    +