diff --git a/AGENTS.md b/AGENTS.md index 1b539c2d..62d7cd92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 --- diff --git a/src/AGENTS.md b/src/AGENTS.md new file mode 100644 index 00000000..bb69e0a9 --- /dev/null +++ b/src/AGENTS.md @@ -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 = { + ...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 diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index dfe9d972..b685eff9 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -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") + }) }) diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 55d6187b..93ea58e9 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -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 | 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 } diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md new file mode 100644 index 00000000..ce20d3dd --- /dev/null +++ b/src/config/AGENTS.md @@ -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" + } +} +``` diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index 59155c47..738d5259 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -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. diff --git a/src/features/background-agent/spawner/background-session-creator.ts b/src/features/background-agent/spawner/background-session-creator.ts new file mode 100644 index 00000000..1088602c --- /dev/null +++ b/src/features/background-agent/spawner/background-session-creator.ts @@ -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 { + 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 +} diff --git a/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts new file mode 100644 index 00000000..7165877c --- /dev/null +++ b/src/features/background-agent/spawner/concurrency-key-from-launch-input.ts @@ -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 +} diff --git a/src/features/background-agent/spawner/parent-directory-resolver.ts b/src/features/background-agent/spawner/parent-directory-resolver.ts new file mode 100644 index 00000000..8894f33a --- /dev/null +++ b/src/features/background-agent/spawner/parent-directory-resolver.ts @@ -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 { + 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 +} diff --git a/src/features/background-agent/spawner/tmux-callback-invoker.ts b/src/features/background-agent/spawner/tmux-callback-invoker.ts new file mode 100644 index 00000000..ed4878b1 --- /dev/null +++ b/src/features/background-agent/spawner/tmux-callback-invoker.ts @@ -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 { + 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((resolve) => setTimeout(resolve, TMUX_CALLBACK_DELAY_MS)) +} diff --git a/src/features/claude-tasks/session-storage.test.ts b/src/features/claude-tasks/session-storage.test.ts new file mode 100644 index 00000000..7b3e5a5b --- /dev/null +++ b/src/features/claude-tasks/session-storage.test.ts @@ -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 { + 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() + }) +}) diff --git a/src/features/claude-tasks/session-storage.ts b/src/features/claude-tasks/session-storage.ts new file mode 100644 index 00000000..749f9c1b --- /dev/null +++ b/src/features/claude-tasks/session-storage.ts @@ -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, + sessionID: string, +): string { + return join(getTaskDir(config), sessionID) +} + +export function listSessionTaskFiles( + config: Partial, + 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, +): 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, + 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 +} diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md new file mode 100644 index 00000000..74dcf9a7 --- /dev/null +++ b/src/plugin-handlers/AGENTS.md @@ -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() +]); +```