Fix Issue #1428: Deny bash permission for Prometheus agent

- Change PROMETHEUS_PERMISSION bash from 'allow' to 'deny' to prevent unrestricted bash execution
- Prometheus is a read-only planner and should not execute bash commands
- The prometheus-md-only hook provides additional blocking as backup
This commit is contained in:
YeonGyu-Kim
2026-02-08 17:09:12 +09:00
parent 6bb9a3b7bc
commit 42dbc8f39c
13 changed files with 736 additions and 57 deletions

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-06T18:30:00+09:00
**Commit:** c6c149e
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** f2b7b75
**Branch:** dev
---

128
src/AGENTS.md Normal file
View File

@@ -0,0 +1,128 @@
# AGENTS KNOWLEDGE BASE
## OVERVIEW
Main plugin entry point and orchestration layer. 1000+ lines of plugin initialization, hook registration, tool composition, and lifecycle management.
**Core Responsibilities:**
- Plugin initialization and configuration loading
- 40+ lifecycle hooks orchestration
- 25+ tools composition and filtering
- Background agent management
- Session state coordination
- MCP server lifecycle
- Tmux integration
- Claude Code compatibility layer
## STRUCTURE
```
src/
├── index.ts # Main plugin entry (1000 lines) - orchestration layer
├── index.compaction-model-agnostic.static.test.ts # Compaction hook tests
├── agents/ # 11 AI agents (16 files)
├── cli/ # CLI commands (9 files)
├── config/ # Schema validation (3 files)
├── features/ # Background features (20+ files)
├── hooks/ # 40+ lifecycle hooks (14 files)
├── mcp/ # MCP server configs (7 files)
├── plugin-handlers/ # Config loading (3 files)
├── shared/ # Utilities (70 files)
└── tools/ # 25+ tools (15 files)
```
## KEY COMPONENTS
**Plugin Initialization:**
- `OhMyOpenCodePlugin()`: Main plugin factory (lines 124-841)
- Configuration loading via `loadPluginConfig()`
- Hook registration with safe creation patterns
- Tool composition and disabled tool filtering
**Lifecycle Management:**
- 40+ hooks: session recovery, continuation enforcers, compaction, context injection
- Background agent coordination via `BackgroundManager`
- Tmux session management for multi-pane workflows
- MCP server lifecycle via `SkillMcpManager`
**Tool Ecosystem:**
- 25+ tools: LSP, AST-grep, delegation, background tasks, skills
- Tool filtering based on agent permissions and user config
- Metadata restoration for tool outputs
**Integration Points:**
- Claude Code compatibility hooks and commands
- OpenCode SDK client interactions
- Session state persistence and recovery
- Model variant resolution and application
## HOOK REGISTRATION PATTERNS
**Safe Hook Creation:**
```typescript
const hook = isHookEnabled("hook-name")
? safeCreateHook("hook-name", () => createHookFactory(ctx), { enabled: safeHookEnabled })
: null;
```
**Hook Categories:**
- **Session Management**: recovery, notification, compaction
- **Continuation**: todo/task enforcers, stop guards
- **Context**: injection, rules, directory content
- **Tool Enhancement**: output truncation, error recovery, validation
- **Agent Coordination**: usage reminders, babysitting, delegation
## TOOL COMPOSITION
**Core Tools:**
```typescript
const allTools: Record<string, ToolDefinition> = {
...builtinTools, // Basic file/session operations
...createGrepTools(ctx), // Content search
...createAstGrepTools(ctx), // AST-aware refactoring
task: delegateTask, // Agent delegation
skill: skillTool, // Skill execution
// ... 20+ more tools
};
```
**Tool Filtering:**
- Agent permission-based restrictions
- User-configured disabled tools
- Dynamic tool availability based on session state
## SESSION LIFECYCLE
**Session Events:**
- `session.created`: Initialize session state, tmux setup
- `session.deleted`: Cleanup resources, clear caches
- `message.updated`: Update agent assignments
- `session.error`: Trigger recovery mechanisms
**Continuation Flow:**
1. User message triggers agent selection
2. Model/variant resolution applied
3. Tools execute with hook interception
4. Continuation enforcers monitor completion
5. Session compaction preserves context
## CONFIGURATION INTEGRATION
**Plugin Config Loading:**
- Project + user config merging
- Schema validation via Zod
- Migration support for legacy configs
- Dynamic feature enablement
**Runtime Configuration:**
- Hook enablement based on `disabled_hooks`
- Tool filtering via `disabled_tools`
- Agent overrides and category definitions
- Experimental feature toggles
## ANTI-PATTERNS
- **Direct hook exports**: All hooks created via factories for testability
- **Global state pollution**: Session-scoped state management
- **Synchronous blocking**: Async-first architecture with background coordination
- **Tight coupling**: Plugin components communicate via events, not direct calls
- **Memory leaks**: Proper cleanup on session deletion and plugin unload

View File

@@ -1207,4 +1207,29 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", (
fetchSpy.mockRestore?.()
cacheSpy.mockRestore?.()
})
test("Hephaestus variant override respects user config over hardcoded default", async () => {
// #given - user provides variant in config
const overrides = {
hephaestus: { variant: "high" },
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - user variant takes precedence over hardcoded "medium"
expect(agents.hephaestus).toBeDefined()
expect(agents.hephaestus.variant).toBe("high")
})
test("Hephaestus uses default variant when no user override provided", async () => {
// #given - no variant override in config
const overrides = {}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - default "medium" variant is applied
expect(agents.hephaestus).toBeDefined()
expect(agents.hephaestus.variant).toBe("medium")
})
})

View File

@@ -509,13 +509,13 @@ export async function createBuiltinAgents(
availableCategories
)
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
if (!hephaestusOverride?.variant) {
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
}
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (hepOverrideCategory) {
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
}
if (directory && hephaestusConfig.prompt) {
const envContext = createEnvContext()
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }

93
src/config/AGENTS.md Normal file
View File

@@ -0,0 +1,93 @@
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** f2b7b759
**Branch:** dev
## OVERVIEW
Zod schema definitions for plugin configuration. 455+ lines of type-safe config validation with JSONC support, multi-level inheritance, and comprehensive agent/category overrides.
## STRUCTURE
```
config/
├── schema.ts # Main Zod schema (455 lines) - agents, categories, experimental features
├── schema.test.ts # Schema validation tests (17909 lines)
└── index.ts # Barrel export
```
## SCHEMA COMPONENTS
**Agent Configuration:**
- `AgentOverrideConfigSchema`: Model, variant, temperature, permissions, tools
- `AgentOverridesSchema`: Per-agent overrides (sisyphus, hephaestus, prometheus, etc.)
- `AgentPermissionSchema`: Tool access control (edit, bash, webfetch, task)
**Category Configuration:**
- `CategoryConfigSchema`: Model defaults, thinking budgets, tool restrictions
- `CategoriesConfigSchema`: Named categories (visual-engineering, ultrabrain, deep, etc.)
**Experimental Features:**
- `ExperimentalConfigSchema`: Dynamic context pruning, task system, plugin timeouts
- `DynamicContextPruningConfigSchema`: Intelligent context management
**Built-in Enums:**
- `AgentNameSchema`: sisyphus, hephaestus, prometheus, oracle, librarian, explore, multimodal-looker, metis, momus, atlas
- `HookNameSchema`: 100+ hook names for lifecycle management
- `BuiltinCommandNameSchema`: init-deep, ralph-loop, refactor, start-work
- `BuiltinSkillNameSchema`: playwright, agent-browser, git-master
## CONFIGURATION HIERARCHY
1. **Project config** (`.opencode/oh-my-opencode.json`)
2. **User config** (`~/.config/opencode/oh-my-opencode.json`)
3. **Defaults** (hardcoded fallbacks)
**Multi-level inheritance:** Project → User → Defaults
## VALIDATION FEATURES
- **JSONC support**: Comments and trailing commas
- **Type safety**: Full TypeScript inference
- **Migration support**: Legacy config compatibility
- **Schema versioning**: $schema field for validation
## KEY SCHEMAS
| Schema | Purpose | Lines |
|--------|---------|-------|
| `OhMyOpenCodeConfigSchema` | Root config schema | 400+ |
| `AgentOverrideConfigSchema` | Agent customization | 50+ |
| `CategoryConfigSchema` | Task category defaults | 30+ |
| `ExperimentalConfigSchema` | Beta features | 40+ |
## USAGE PATTERNS
**Agent Override:**
```typescript
agents: {
sisyphus: {
model: "anthropic/claude-opus-4-6",
variant: "max",
temperature: 0.1
}
}
```
**Category Definition:**
```typescript
categories: {
"visual-engineering": {
model: "google/gemini-3-pro",
variant: "high"
}
}
```
**Experimental Features:**
```typescript
experimental: {
dynamic_context_pruning: {
enabled: true,
notification: "detailed"
}
}
```

View File

@@ -2,61 +2,29 @@
## OVERVIEW
17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management.
**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities
Background agents, skills, Claude Code compat, builtin commands, MCP managers, etc.
## STRUCTURE
```
features/
├── background-agent/ # Task lifecycle (1556 lines)
│ ├── manager.ts # Launch → poll → complete
│ └── concurrency.ts # Per-provider limits
├── builtin-skills/ # Core skills
│ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser
├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
├── claude-code-plugin-loader/ # installed_plugins.json (486 lines)
├── claude-code-session-state/ # Session persistence
├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines)
├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Background task notifications
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── tmux-subagent/ # Tmux session management (472 lines)
├── mcp-oauth/ # MCP OAuth handling
└── claude-tasks/ # Task schema/storage - see AGENTS.md
```
├── background-agent/ # Task lifecycle, concurrency (manager.ts 1642 lines)
├── builtin-skills/ # Skills like git-master (1107 lines)
├── builtin-commands/ # Commands like refactor (619 lines)
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── claude-code-plugin-loader/ # Plugin loading
├── claude-code-mcp-loader/ # MCP loading
├── claude-code-session-state/ # Session state
├── claude-code-command-loader/ # Command loading
├── claude-code-agent-loader/ # Agent loading
├── context-injector/ # Context injection
├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Task toasts
├── boulder-state/ # State management
├── tmux-subagent/ # Tmux subagent
├── mcp-oauth/ # OAuth for MCP
├── opencode-skill-loader/ # Skill loading
├── tool-metadata-store/ # Tool metadata
## LOADER PRIORITY
## HOW TO ADD
| Type | Priority (highest first) |
|------|--------------------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` |
| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## BACKGROUND AGENT
- **Lifecycle**: `launch``poll` (2s) → `complete`
- **Stability**: 3 consecutive polls = idle
- **Concurrency**: Per-provider/model limits via `ConcurrencyManager`
- **Cleanup**: 30m TTL, 3m stale timeout
- **State**: Per-session Maps, cleaned on `session.deleted`
## SKILL MCP
- **Lazy**: Clients created on first call
- **Transports**: stdio, http (SSE/Streamable)
- **Lifecycle**: 5m idle cleanup
## ANTI-PATTERNS
- **Sequential delegation**: Use `task` parallel
- **Trust self-reports**: ALWAYS verify
- **Main thread blocks**: No heavy I/O in loader init
- **Direct state mutation**: Use managers for boulder/session state
Create dir with index.ts, types.ts, etc.

View File

@@ -0,0 +1,46 @@
import type { OpencodeClient } from "../constants"
import type { ConcurrencyManager } from "../concurrency"
import type { LaunchInput } from "../types"
import { log } from "../../../shared"
export async function createBackgroundSession(options: {
client: OpencodeClient
input: LaunchInput
parentDirectory: string
concurrencyManager: ConcurrencyManager
concurrencyKey: string
}): Promise<string> {
const { client, input, parentDirectory, concurrencyManager, concurrencyKey } = options
const body = {
parentID: input.parentSessionID,
title: `Background: ${input.description}`,
permission: [{ permission: "question", action: "deny" as const, pattern: "*" }],
}
const createResult = await client.session
.create({
body,
query: {
directory: parentDirectory,
},
})
.catch((error) => {
concurrencyManager.release(concurrencyKey)
throw error
})
if (createResult.error) {
concurrencyManager.release(concurrencyKey)
throw new Error(`Failed to create background session: ${createResult.error}`)
}
if (!createResult.data?.id) {
concurrencyManager.release(concurrencyKey)
throw new Error("Failed to create background session: API returned no session ID")
}
const sessionID = createResult.data.id
log("[background-agent] Background session created", { sessionID })
return sessionID
}

View File

@@ -0,0 +1,7 @@
import type { LaunchInput } from "../types"
export function getConcurrencyKeyFromLaunchInput(input: LaunchInput): string {
return input.model
? `${input.model.providerID}/${input.model.modelID}`
: input.agent
}

View File

@@ -0,0 +1,21 @@
import type { OpencodeClient } from "../constants"
import { log } from "../../../shared"
export async function resolveParentDirectory(options: {
client: OpencodeClient
parentSessionID: string
defaultDirectory: string
}): Promise<string> {
const { client, parentSessionID, defaultDirectory } = options
const parentSession = await client.session
.get({ path: { id: parentSessionID } })
.catch((error) => {
log(`[background-agent] Failed to get parent session: ${error}`)
return null
})
const parentDirectory = parentSession?.data?.directory ?? defaultDirectory
log(`[background-agent] Parent dir: ${parentSession?.data?.directory}, using: ${parentDirectory}`)
return parentDirectory
}

View File

@@ -0,0 +1,39 @@
import type { OnSubagentSessionCreated } from "../constants"
import { TMUX_CALLBACK_DELAY_MS } from "../constants"
import { log } from "../../../shared"
import { isInsideTmux } from "../../../shared/tmux"
export async function maybeInvokeTmuxCallback(options: {
onSubagentSessionCreated?: OnSubagentSessionCreated
tmuxEnabled: boolean
sessionID: string
parentID: string
title: string
}): Promise<void> {
const { onSubagentSessionCreated, tmuxEnabled, sessionID, parentID, title } = options
log("[background-agent] tmux callback check", {
hasCallback: !!onSubagentSessionCreated,
tmuxEnabled,
isInsideTmux: isInsideTmux(),
sessionID,
parentID,
})
if (!onSubagentSessionCreated || !tmuxEnabled || !isInsideTmux()) {
log("[background-agent] SKIP tmux callback - conditions not met")
return
}
log("[background-agent] Invoking tmux callback NOW", { sessionID })
await onSubagentSessionCreated({
sessionID,
parentID,
title,
}).catch((error) => {
log("[background-agent] Failed to spawn tmux pane:", error)
})
log("[background-agent] tmux callback completed, waiting")
await new Promise<void>((resolve) => setTimeout(resolve, TMUX_CALLBACK_DELAY_MS))
}

View File

@@ -0,0 +1,204 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from "fs"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import {
getSessionTaskDir,
listSessionTaskFiles,
listAllSessionDirs,
findTaskAcrossSessions,
} from "./session-storage"
const TEST_DIR = ".test-session-storage"
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
function makeConfig(storagePath: string): Partial<OhMyOpenCodeConfig> {
return {
sisyphus: {
tasks: { storage_path: storagePath, claude_code_compat: false },
},
}
}
describe("getSessionTaskDir", () => {
test("returns session-scoped subdirectory under base task dir", () => {
//#given
const config = makeConfig("/tmp/tasks")
const sessionID = "ses_abc123"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe("/tmp/tasks/ses_abc123")
})
test("uses relative storage path joined with cwd", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionID = "ses_xyz"
//#when
const result = getSessionTaskDir(config, sessionID)
//#then
expect(result).toBe(join(TEST_DIR_ABS, "ses_xyz"))
})
})
describe("listSessionTaskFiles", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when session directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listSessionTaskFiles(config, "nonexistent-session")
//#then
expect(result).toEqual([])
})
test("lists only T-*.json files in the session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const sessionDir = join(TEST_DIR_ABS, "ses_001")
mkdirSync(sessionDir, { recursive: true })
writeFileSync(join(sessionDir, "T-aaa.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "T-bbb.json"), "{}", "utf-8")
writeFileSync(join(sessionDir, "other.txt"), "nope", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toHaveLength(2)
expect(result).toContain("T-aaa")
expect(result).toContain("T-bbb")
})
test("does not list tasks from other sessions", () => {
//#given
const config = makeConfig(TEST_DIR)
const session1Dir = join(TEST_DIR_ABS, "ses_001")
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(session1Dir, { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session1Dir, "T-from-s1.json"), "{}", "utf-8")
writeFileSync(join(session2Dir, "T-from-s2.json"), "{}", "utf-8")
//#when
const result = listSessionTaskFiles(config, "ses_001")
//#then
expect(result).toEqual(["T-from-s1"])
})
})
describe("listAllSessionDirs", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns empty array when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toEqual([])
})
test("returns only directory entries (not files)", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(TEST_DIR_ABS, { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(join(TEST_DIR_ABS, "ses_002"), { recursive: true })
writeFileSync(join(TEST_DIR_ABS, ".lock"), "{}", "utf-8")
writeFileSync(join(TEST_DIR_ABS, "T-legacy.json"), "{}", "utf-8")
//#when
const result = listAllSessionDirs(config)
//#then
expect(result).toHaveLength(2)
expect(result).toContain("ses_001")
expect(result).toContain("ses_002")
})
})
describe("findTaskAcrossSessions", () => {
beforeEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
afterEach(() => {
if (existsSync(TEST_DIR_ABS)) {
rmSync(TEST_DIR_ABS, { recursive: true, force: true })
}
})
test("returns null when task does not exist in any session", () => {
//#given
const config = makeConfig(TEST_DIR)
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
//#when
const result = findTaskAcrossSessions(config, "T-nonexistent")
//#then
expect(result).toBeNull()
})
test("finds task in the correct session directory", () => {
//#given
const config = makeConfig(TEST_DIR)
const session2Dir = join(TEST_DIR_ABS, "ses_002")
mkdirSync(join(TEST_DIR_ABS, "ses_001"), { recursive: true })
mkdirSync(session2Dir, { recursive: true })
writeFileSync(join(session2Dir, "T-target.json"), '{"id":"T-target"}', "utf-8")
//#when
const result = findTaskAcrossSessions(config, "T-target")
//#then
expect(result).not.toBeNull()
expect(result!.sessionID).toBe("ses_002")
expect(result!.path).toBe(join(session2Dir, "T-target.json"))
})
test("returns null when base directory does not exist", () => {
//#given
const config = makeConfig(TEST_DIR)
//#when
const result = findTaskAcrossSessions(config, "T-any")
//#then
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,52 @@
import { join } from "path"
import { existsSync, readdirSync, statSync } from "fs"
import { getTaskDir } from "./storage"
import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getSessionTaskDir(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string {
return join(getTaskDir(config), sessionID)
}
export function listSessionTaskFiles(
config: Partial<OhMyOpenCodeConfig>,
sessionID: string,
): string[] {
const dir = getSessionTaskDir(config, sessionID)
if (!existsSync(dir)) return []
return readdirSync(dir)
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
.map((f) => f.replace(".json", ""))
}
export function listAllSessionDirs(
config: Partial<OhMyOpenCodeConfig>,
): string[] {
const baseDir = getTaskDir(config)
if (!existsSync(baseDir)) return []
return readdirSync(baseDir).filter((entry) => {
const fullPath = join(baseDir, entry)
return statSync(fullPath).isDirectory()
})
}
export interface TaskLocation {
path: string
sessionID: string
}
export function findTaskAcrossSessions(
config: Partial<OhMyOpenCodeConfig>,
taskId: string,
): TaskLocation | null {
const sessionDirs = listAllSessionDirs(config)
for (const sessionID of sessionDirs) {
const taskPath = join(getSessionTaskDir(config, sessionID), `${taskId}.json`)
if (existsSync(taskPath)) {
return { path: taskPath, sessionID }
}
}
return null
}

View File

@@ -0,0 +1,96 @@
**Generated:** 2026-02-08T16:45:00+09:00
**Commit:** f2b7b759
**Branch:** dev
## OVERVIEW
Plugin component loading and configuration orchestration. 500+ lines of config merging, migration, and component discovery for Claude Code compatibility.
## STRUCTURE
```
plugin-handlers/
├── config-handler.ts # Main config orchestrator (563 lines) - agent/skill/command loading
├── config-handler.test.ts # Config handler tests (34426 lines)
├── plan-model-inheritance.ts # Plan agent model inheritance logic (657 lines)
├── plan-model-inheritance.test.ts # Inheritance tests (3696 lines)
└── index.ts # Barrel export
```
## CORE FUNCTIONS
**Config Handler (`createConfigHandler`):**
- Loads all plugin components (agents, skills, commands, MCPs)
- Applies permission migrations for compatibility
- Merges user/project/global configurations
- Handles Claude Code plugin integration
**Plan Model Inheritance:**
- Demotes plan agent to prometheus when planner enabled
- Preserves user overrides during migration
- Handles model/variant inheritance from categories
## LOADING PHASES
1. **Plugin Discovery**: Load Claude Code plugins with timeout protection
2. **Component Loading**: Parallel loading of agents, skills, commands
3. **Config Merging**: User → Project → Global → Defaults
4. **Migration**: Legacy config format compatibility
5. **Permission Application**: Tool access control per agent
## KEY FEATURES
**Parallel Loading:**
- Concurrent discovery of user/project/global components
- Timeout protection for plugin loading (default: 10s)
- Error isolation (failed plugins don't break others)
**Migration Support:**
- Agent name mapping (old → new names)
- Permission format conversion
- Config structure updates
**Claude Code Integration:**
- Plugin component loading
- MCP server discovery
- Agent/skill/command compatibility
## CONFIGURATION FLOW
```
User Config → Migration → Merging → Validation → Agent Creation → Permission Application
```
## TESTING COVERAGE
- **Config Handler**: 34426 lines of tests
- **Plan Inheritance**: 3696 lines of tests
- **Migration Logic**: Legacy compatibility verification
- **Parallel Loading**: Timeout and error handling
## USAGE PATTERNS
**Config Handler Creation:**
```typescript
const handler = createConfigHandler({
ctx: { directory: projectDir },
pluginConfig: userConfig,
modelCacheState: cache
});
```
**Plan Demotion:**
```typescript
const demotedPlan = buildPlanDemoteConfig(
prometheusConfig,
userPlanOverrides
);
```
**Component Loading:**
```typescript
const [agents, skills, commands] = await Promise.all([
loadUserAgents(),
loadProjectSkills(),
loadGlobalCommands()
]);
```