Browse Source

Initial clean version

Alvin Unreal 2 months ago
commit
58d33e2292

+ 37 - 0
.gitignore

@@ -0,0 +1,37 @@
+# Dependencies
+node_modules/
+
+# Build output
+dist/
+
+# Environment files
+.env
+.env.local
+.env.*.local
+
+# Editor/IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS files
+.DS_Store
+Thumbs.db
+
+# Logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Test coverage
+coverage/
+
+# Temporary files
+tmp/
+temp/

+ 35 - 0
bun.lock

@@ -0,0 +1,35 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 1,
+  "workspaces": {
+    "": {
+      "name": "oh-my-opencode-lite",
+      "dependencies": {
+        "@opencode-ai/plugin": "^1.1.19",
+        "@opencode-ai/sdk": "^1.1.19",
+        "zod": "^4.1.8",
+      },
+      "devDependencies": {
+        "bun-types": "latest",
+        "typescript": "^5.7.3",
+      },
+    },
+  },
+  "packages": {
+    "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.21", "", { "dependencies": { "@opencode-ai/sdk": "1.1.21", "zod": "4.1.8" } }, "sha512-oAWVlKG7LACGFYawfdHGMN6e+6lyN6F+zPVncFUB99BrTl/TjELE5gTZwU7MalGpjwfU77yslBOZm4BXVAYGvw=="],
+
+    "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.21", "", {}, "sha512-4M6lBjRPlPz99Rb5rS5ZqKrb0UDDxOT9VTG06JpNxvA7ynTd8C50ckc2NGzWtvjarmxfaAk1VeuBYN/cq2pIKQ=="],
+
+    "@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="],
+
+    "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
+
+    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+    "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+    "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
+
+    "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+  }
+}

+ 23 - 0
package.json

@@ -0,0 +1,23 @@
+{
+  "name": "oh-my-opencode-lite",
+  "version": "0.1.0",
+  "description": "Minimal agent orchestration plugin for OpenCode",
+  "main": "dist/index.js",
+  "types": "dist/index.d.ts",
+  "type": "module",
+  "scripts": {
+    "build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly",
+    "typecheck": "tsc --noEmit",
+    "test": "bun test",
+    "dev": "bun run build && opencode"
+  },
+  "dependencies": {
+    "@opencode-ai/plugin": "^1.1.19",
+    "@opencode-ai/sdk": "^1.1.19",
+    "zod": "^4.1.8"
+  },
+  "devDependencies": {
+    "bun-types": "latest",
+    "typescript": "^5.7.3"
+  }
+}

+ 34 - 0
src/agents/document-writer.ts

@@ -0,0 +1,34 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createDocumentWriterAgent(model: string): AgentDefinition {
+  return {
+    name: "document-writer",
+    config: {
+      model,
+      temperature: 0.3,
+      system: DOCUMENT_WRITER_PROMPT,
+    },
+  };
+}
+
+const DOCUMENT_WRITER_PROMPT = `You are a Technical Writer - crafting clear, comprehensive documentation.
+
+**Role**: README files, API docs, architecture docs, user guides.
+
+**Capabilities**:
+- Clear, scannable structure
+- Appropriate level of detail
+- Code examples that work
+- Consistent terminology
+
+**Output Style**:
+- Use headers for organization
+- Include code examples
+- Add tables for structured data
+- Keep paragraphs short
+
+**Constraints**:
+- Match existing doc style if present
+- Don't over-document obvious code
+- Focus on "why" not just "what"`;

+ 38 - 0
src/agents/explore.ts

@@ -0,0 +1,38 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createExploreAgent(model: string): AgentDefinition {
+  return {
+    name: "explore",
+    config: {
+      model,
+      temperature: 0.1,
+      system: EXPLORE_PROMPT,
+    },
+  };
+}
+
+const EXPLORE_PROMPT = `You are Explorer - a fast codebase navigation specialist.
+
+**Role**: Quick contextual grep for codebases. Answer "Where is X?", "Find Y", "Which file has Z".
+
+**Behavior**:
+- Be fast and thorough
+- Use grep, glob, ast_grep_search
+- Return file paths with relevant snippets
+- Fire multiple searches if needed
+
+**Output Format**:
+<results>
+<files>
+- /path/to/file.ts — Brief description of what's there
+</files>
+<answer>
+Concise answer to the question
+</answer>
+</results>
+
+**Constraints**:
+- READ-ONLY: Search and report, don't modify
+- Be exhaustive but concise
+- Include line numbers when relevant`;

+ 36 - 0
src/agents/frontend.ts

@@ -0,0 +1,36 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createFrontendAgent(model: string): AgentDefinition {
+  return {
+    name: "frontend-ui-ux-engineer",
+    config: {
+      model,
+      temperature: 0.7,
+      system: FRONTEND_PROMPT,
+    },
+  };
+}
+
+const FRONTEND_PROMPT = `You are a Frontend UI/UX Engineer - a designer turned developer.
+
+**Role**: Craft stunning UI/UX even without design mockups.
+
+**Capabilities**:
+- Modern, beautiful, responsive interfaces
+- CSS/Tailwind mastery
+- Component architecture
+- Micro-animations and polish
+
+**Design Principles**:
+- Rich aesthetics that wow at first glance
+- Harmonious color palettes (avoid generic red/blue/green)
+- Modern typography (Inter, Roboto, Outfit)
+- Smooth gradients and subtle shadows
+- Micro-animations for engagement
+- Mobile-first responsive design
+
+**Constraints**:
+- Match existing design system if present
+- Use existing component libraries when available
+- Prioritize visual excellence over code perfection`;

+ 53 - 0
src/agents/index.ts

@@ -0,0 +1,53 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import { DEFAULT_MODELS, type AgentName, type PluginConfig } from "../config";
+import { createOrchestratorAgent, type AgentDefinition } from "./orchestrator";
+import { createOracleAgent } from "./oracle";
+import { createLibrarianAgent } from "./librarian";
+import { createExploreAgent } from "./explore";
+import { createFrontendAgent } from "./frontend";
+import { createDocumentWriterAgent } from "./document-writer";
+import { createMultimodalAgent } from "./multimodal";
+
+export type { AgentDefinition } from "./orchestrator";
+
+type AgentFactory = (model: string) => AgentDefinition;
+
+const AGENT_FACTORIES: Record<AgentName, AgentFactory> = {
+  Orchestrator: createOrchestratorAgent,
+  oracle: createOracleAgent,
+  librarian: createLibrarianAgent,
+  explore: createExploreAgent,
+  "frontend-ui-ux-engineer": createFrontendAgent,
+  "document-writer": createDocumentWriterAgent,
+  "multimodal-looker": createMultimodalAgent,
+};
+
+export function createAgents(config?: PluginConfig): AgentDefinition[] {
+  const disabledAgents = new Set(config?.disabled_agents ?? []);
+  const agentOverrides = config?.agents ?? {};
+
+  return Object.entries(AGENT_FACTORIES)
+    .filter(([name]) => !disabledAgents.has(name))
+    .map(([name, factory]) => {
+      const override = agentOverrides[name];
+      const model = override?.model ?? DEFAULT_MODELS[name as AgentName];
+      const agent = factory(model);
+
+      if (override?.temperature !== undefined) {
+        agent.config.temperature = override.temperature;
+      }
+      if (override?.prompt) {
+        agent.config.system = override.prompt;
+      }
+      if (override?.prompt_append) {
+        agent.config.system = `${agent.config.system}\n\n${override.prompt_append}`;
+      }
+
+      return agent;
+    });
+}
+
+export function getAgentConfigs(config?: PluginConfig): Record<string, AgentConfig> {
+  const agents = createAgents(config);
+  return Object.fromEntries(agents.map((a) => [a.name, a.config]));
+}

+ 34 - 0
src/agents/librarian.ts

@@ -0,0 +1,34 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createLibrarianAgent(model: string): AgentDefinition {
+  return {
+    name: "librarian",
+    config: {
+      model,
+      temperature: 0.1,
+      system: LIBRARIAN_PROMPT,
+    },
+  };
+}
+
+const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebases and documentation.
+
+**Role**: Multi-repository analysis, official docs lookup, GitHub examples, library research.
+
+**Capabilities**:
+- Search and analyze external repositories
+- Find official documentation for libraries
+- Locate implementation examples in open source
+- Understand library internals and best practices
+
+**Tools to Use**:
+- context7: Official documentation lookup
+- grep_app: Search GitHub repositories
+- websearch: General web search for docs
+
+**Behavior**:
+- Provide evidence-based answers with sources
+- Quote relevant code snippets
+- Link to official docs when available
+- Distinguish between official and community patterns`;

+ 34 - 0
src/agents/multimodal.ts

@@ -0,0 +1,34 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createMultimodalAgent(model: string): AgentDefinition {
+  return {
+    name: "multimodal-looker",
+    config: {
+      model,
+      temperature: 0.1,
+      system: MULTIMODAL_PROMPT,
+    },
+  };
+}
+
+const MULTIMODAL_PROMPT = `You are a Multimodal Analyst - extracting information from visual content.
+
+**Role**: Analyze PDFs, images, diagrams, screenshots.
+
+**Capabilities**:
+- Extract text and structure from documents
+- Describe visual content accurately
+- Interpret diagrams and flowcharts
+- Summarize lengthy documents
+
+**Output Style**:
+- Be specific about what you see
+- Quote exact text when relevant
+- Describe layout and structure
+- Note any unclear or ambiguous elements
+
+**Constraints**:
+- Report what you observe, don't infer excessively
+- Ask for clarification if image is unclear
+- Preserve original terminology from documents`;

+ 34 - 0
src/agents/oracle.ts

@@ -0,0 +1,34 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentDefinition } from "./orchestrator";
+
+export function createOracleAgent(model: string): AgentDefinition {
+  return {
+    name: "oracle",
+    config: {
+      model,
+      temperature: 0.1,
+      system: ORACLE_PROMPT,
+    },
+  };
+}
+
+const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor.
+
+**Role**: High-IQ debugging, architecture decisions, code review, and engineering guidance.
+
+**Capabilities**:
+- Analyze complex codebases and identify root causes
+- Propose architectural solutions with tradeoffs
+- Review code for correctness, performance, and maintainability
+- Guide debugging when standard approaches fail
+
+**Behavior**:
+- Be direct and concise
+- Provide actionable recommendations
+- Explain reasoning briefly
+- Acknowledge uncertainty when present
+
+**Constraints**:
+- READ-ONLY: You advise, you don't implement
+- Focus on strategy, not execution
+- Point to specific files/lines when relevant`;

+ 72 - 0
src/agents/orchestrator.ts

@@ -0,0 +1,72 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+
+export interface AgentDefinition {
+  name: string;
+  config: AgentConfig;
+}
+
+export function createOrchestratorAgent(model: string): AgentDefinition {
+  return {
+    name: "Orchestrator",
+    config: {
+      model,
+      temperature: 0.1,
+      system: ORCHESTRATOR_PROMPT,
+    },
+  };
+}
+
+const ORCHESTRATOR_PROMPT = `<Role>
+You are an AI coding orchestrator with access to specialized subagents.
+
+**Core Competencies**:
+- Parse implicit requirements from explicit requests
+- Delegate specialized work to the right subagents
+- Parallel execution for maximum throughput
+- Write code indistinguishable from a senior engineer
+
+**Operating Mode**: Delegate when specialists are available. Frontend → delegate. Research → parallel background agents. Complex architecture → consult Oracle.
+</Role>
+
+<Subagents>
+| Agent | Purpose | When to Use |
+|-------|---------|-------------|
+| @oracle | Architecture, debugging, code review | Complex decisions, after 2+ failed attempts |
+| @librarian | Docs, GitHub examples, library research | External library questions |
+| @explore | Fast codebase grep | "Find X", "Where is Y", codebase patterns |
+| @frontend-ui-ux-engineer | UI/UX implementation | Visual/styling changes |
+| @document-writer | Technical documentation | README, API docs |
+</Subagents>
+
+<Delegation>
+## Background Tasks
+Use background_task for parallel work:
+\`\`\`
+background_task(agent="explore", prompt="Find all auth implementations")
+background_task(agent="librarian", prompt="How does library X handle Y")
+\`\`\`
+
+## When to Delegate
+- Frontend visual work → frontend-ui-ux-engineer
+- Documentation → document-writer  
+- Research → librarian (background)
+- Codebase search → explore (background, fire multiple)
+- Complex architecture → oracle (consult first)
+</Delegation>
+
+<Workflow>
+1. Understand the request fully
+2. If multi-step: create TODO list first
+3. For search: fire parallel explore agents
+4. Use LSP tools for refactoring (safer than text edits)
+5. Verify with lsp_diagnostics after changes
+6. Mark TODOs complete as you finish each
+</Workflow>
+
+<Rules>
+- NEVER use \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
+- NEVER commit without explicit request
+- NEVER stop until all TODOs are done
+- Ask for clarification if scope is ambiguous
+- Match existing codebase patterns
+</Rules>`;

+ 2 - 0
src/config/index.ts

@@ -0,0 +1,2 @@
+export * from "./schema";
+export { loadPluginConfig } from "./loader";

+ 92 - 0
src/config/loader.ts

@@ -0,0 +1,92 @@
+import * as fs from "fs";
+import * as path from "path";
+import * as os from "os";
+import { PluginConfigSchema, type PluginConfig } from "./schema";
+
+function getUserConfigDir(): string {
+  if (process.platform === "win32") {
+    return process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
+  }
+  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
+}
+
+function loadConfigFromPath(configPath: string): PluginConfig | null {
+  try {
+    if (fs.existsSync(configPath)) {
+      const content = fs.readFileSync(configPath, "utf-8");
+      const rawConfig = JSON.parse(content);
+      const result = PluginConfigSchema.safeParse(rawConfig);
+      
+      if (!result.success) {
+        console.error(`[lite] Config validation error in ${configPath}:`, result.error.issues);
+        return null;
+      }
+      
+      console.log(`[lite] Config loaded from ${configPath}`);
+      return result.data;
+    }
+  } catch (err) {
+    console.error(`[lite] Error loading config from ${configPath}:`, err);
+  }
+  return null;
+}
+
+function deepMerge<T extends Record<string, unknown>>(base?: T, override?: T): T | undefined {
+  if (!base) return override;
+  if (!override) return base;
+  
+  const result = { ...base } as T;
+  for (const key of Object.keys(override) as (keyof T)[]) {
+    const baseVal = base[key];
+    const overrideVal = override[key];
+    
+    if (
+      typeof baseVal === "object" && baseVal !== null &&
+      typeof overrideVal === "object" && overrideVal !== null &&
+      !Array.isArray(baseVal) && !Array.isArray(overrideVal)
+    ) {
+      result[key] = deepMerge(
+        baseVal as Record<string, unknown>,
+        overrideVal as Record<string, unknown>
+      ) as T[keyof T];
+    } else {
+      result[key] = overrideVal;
+    }
+  }
+  return result;
+}
+
+export function loadPluginConfig(directory: string): PluginConfig {
+  const userConfigPath = path.join(
+    getUserConfigDir(),
+    "opencode",
+    "oh-my-opencode-lite.json"
+  );
+  
+  const projectConfigPath = path.join(directory, ".opencode", "oh-my-opencode-lite.json");
+
+  let config: PluginConfig = loadConfigFromPath(userConfigPath) ?? {};
+  
+  const projectConfig = loadConfigFromPath(projectConfigPath);
+  if (projectConfig) {
+    config = {
+      ...config,
+      ...projectConfig,
+      agents: deepMerge(config.agents, projectConfig.agents),
+      disabled_agents: [
+        ...new Set([
+          ...(config.disabled_agents ?? []),
+          ...(projectConfig.disabled_agents ?? []),
+        ]),
+      ],
+      disabled_hooks: [
+        ...new Set([
+          ...(config.disabled_hooks ?? []),
+          ...(projectConfig.disabled_hooks ?? []),
+        ]),
+      ],
+    };
+  }
+
+  return config;
+}

+ 41 - 0
src/config/schema.ts

@@ -0,0 +1,41 @@
+import { z } from "zod";
+
+// Agent configuration
+export const AgentConfigSchema = z.object({
+  model: z.string().optional(),
+  temperature: z.number().min(0).max(2).optional(),
+  prompt: z.string().optional(),
+  prompt_append: z.string().optional(),
+  disable: z.boolean().optional(),
+});
+
+export type AgentConfig = z.infer<typeof AgentConfigSchema>;
+
+// Main plugin config
+export const PluginConfigSchema = z.object({
+  agents: z.record(z.string(), AgentConfigSchema).optional(),
+  disabled_agents: z.array(z.string()).optional(),
+  disabled_hooks: z.array(z.string()).optional(),
+});
+
+export type PluginConfig = z.infer<typeof PluginConfigSchema>;
+
+// Agent names
+export type AgentName =
+  | "Orchestrator"
+  | "oracle"
+  | "librarian"
+  | "explore"
+  | "frontend-ui-ux-engineer"
+  | "document-writer"
+  | "multimodal-looker";
+
+export const DEFAULT_MODELS: Record<AgentName, string> = {
+  Orchestrator: "anthropic/claude-sonnet-4-5",
+  oracle: "openai/gpt-4.1",
+  librarian: "anthropic/claude-sonnet-4-5",
+  explore: "anthropic/claude-haiku-4-5",
+  "frontend-ui-ux-engineer": "google/gemini-2.5-pro",
+  "document-writer": "google/gemini-2.5-pro",
+  "multimodal-looker": "google/gemini-2.5-flash",
+};

+ 176 - 0
src/features/background-manager.ts

@@ -0,0 +1,176 @@
+import type { PluginInput } from "@opencode-ai/plugin";
+
+type OpencodeClient = PluginInput["client"];
+
+export interface BackgroundTask {
+  id: string;
+  sessionId: string;
+  description: string;
+  agent: string;
+  status: "pending" | "running" | "completed" | "failed";
+  result?: string;
+  error?: string;
+  startedAt: Date;
+  completedAt?: Date;
+}
+
+export interface LaunchOptions {
+  agent: string;
+  prompt: string;
+  description: string;
+  parentSessionId: string;
+  model?: string;
+}
+
+function generateTaskId(): string {
+  return `bg_${Math.random().toString(36).substring(2, 10)}`;
+}
+
+export class BackgroundTaskManager {
+  private tasks = new Map<string, BackgroundTask>();
+  private client: OpencodeClient;
+  private directory: string;
+  private pollInterval?: ReturnType<typeof setInterval>;
+
+  constructor(ctx: PluginInput) {
+    this.client = ctx.client;
+    this.directory = ctx.directory;
+  }
+
+  async launch(opts: LaunchOptions): Promise<BackgroundTask> {
+    const session = await this.client.session.create({
+      body: {
+        parentID: opts.parentSessionId,
+        title: `Background: ${opts.description}`,
+      },
+      query: { directory: this.directory },
+    });
+
+    if (!session.data?.id) {
+      throw new Error("Failed to create background session");
+    }
+
+    const task: BackgroundTask = {
+      id: generateTaskId(),
+      sessionId: session.data.id,
+      description: opts.description,
+      agent: opts.agent,
+      status: "running",
+      startedAt: new Date(),
+    };
+
+    this.tasks.set(task.id, task);
+    this.startPolling();
+
+    const promptQuery: Record<string, string> = {
+      directory: this.directory,
+      agent: opts.agent,
+    };
+    if (opts.model) {
+      promptQuery.model = opts.model;
+    }
+
+    await this.client.session.prompt({
+      path: { id: session.data.id },
+      body: {
+        parts: [{ type: "text", text: opts.prompt }],
+      },
+      query: promptQuery,
+    });
+
+    return task;
+  }
+
+  async getResult(taskId: string, block = false, timeout = 120000): Promise<BackgroundTask | null> {
+    const task = this.tasks.get(taskId);
+    if (!task) return null;
+
+    if (!block || task.status === "completed" || task.status === "failed") {
+      return task;
+    }
+
+    const deadline = Date.now() + timeout;
+    while (Date.now() < deadline) {
+      await this.pollTask(task);
+      const status = task.status as string;
+      if (status === "completed" || status === "failed") {
+        return task;
+      }
+      await new Promise((r) => setTimeout(r, 1000));
+    }
+
+    return task;
+  }
+
+  cancel(taskId?: string): number {
+    if (taskId) {
+      const task = this.tasks.get(taskId);
+      if (task && task.status === "running") {
+        task.status = "failed";
+        task.error = "Cancelled by user";
+        task.completedAt = new Date();
+        return 1;
+      }
+      return 0;
+    }
+
+    let count = 0;
+    for (const task of this.tasks.values()) {
+      if (task.status === "running") {
+        task.status = "failed";
+        task.error = "Cancelled by user";
+        task.completedAt = new Date();
+        count++;
+      }
+    }
+    return count;
+  }
+
+  private startPolling() {
+    if (this.pollInterval) return;
+    this.pollInterval = setInterval(() => this.pollAllTasks(), 2000);
+  }
+
+  private async pollAllTasks() {
+    const runningTasks = [...this.tasks.values()].filter((t) => t.status === "running");
+    if (runningTasks.length === 0 && this.pollInterval) {
+      clearInterval(this.pollInterval);
+      this.pollInterval = undefined;
+      return;
+    }
+
+    for (const task of runningTasks) {
+      await this.pollTask(task);
+    }
+  }
+
+  private async pollTask(task: BackgroundTask) {
+    try {
+      const session = await this.client.session.get({
+        path: { id: task.sessionId },
+      });
+
+      const sessionData = session.data as { share?: { messages?: Array<{ role: string; parts?: Array<{ type: string; text?: string }> }> } } | undefined;
+      const messages = sessionData?.share?.messages ?? [];
+      const assistantMessages = messages.filter((m) => m.role === "assistant");
+      const lastMessage = assistantMessages[assistantMessages.length - 1];
+
+      if (lastMessage?.parts) {
+        const textContent = lastMessage.parts
+          .filter((p) => p.type === "text" && p.text)
+          .map((p) => p.text)
+          .join("\n");
+
+        if (textContent) {
+          task.result = textContent;
+          task.status = "completed";
+          task.completedAt = new Date();
+        }
+      }
+    } catch (error) {
+      task.status = "failed";
+      task.error = error instanceof Error ? error.message : String(error);
+      task.completedAt = new Date();
+    }
+  }
+}

+ 1 - 0
src/features/index.ts

@@ -0,0 +1 @@
+export { BackgroundTaskManager, type BackgroundTask, type LaunchOptions } from "./background-manager";

+ 42 - 0
src/index.ts

@@ -0,0 +1,42 @@
+import type { Plugin } from "@opencode-ai/plugin";
+import { getAgentConfigs } from "./agents";
+import { BackgroundTaskManager } from "./features";
+import { createBackgroundTools } from "./tools";
+import { loadPluginConfig } from "./config";
+
+const OhMyOpenCodeLite: Plugin = async (ctx) => {
+  const config = loadPluginConfig(ctx.directory);
+  const agents = getAgentConfigs(config);
+  const backgroundManager = new BackgroundTaskManager(ctx);
+  const backgroundTools = createBackgroundTools(ctx, backgroundManager);
+
+  return {
+    name: "oh-my-opencode-lite",
+
+    agent: agents,
+
+    tool: backgroundTools,
+
+    config: async (opencodeConfig: Record<string, unknown>) => {
+      (opencodeConfig as { default_agent?: string }).default_agent = "Orchestrator";
+
+      const configAgent = opencodeConfig.agent as Record<string, unknown> | undefined;
+      if (!configAgent) {
+        opencodeConfig.agent = { ...agents };
+      } else {
+        Object.assign(configAgent, agents);
+      }
+    },
+
+    event: async (input) => {
+      const { event } = input;
+      if (event.type === "session.created") {
+        console.log("[lite] Session created");
+      }
+    },
+  };
+};
+
+export default OhMyOpenCodeLite;
+
+export type { PluginConfig, AgentConfig, AgentName } from "./config";

+ 257 - 0
src/tools/background.ts

@@ -0,0 +1,257 @@
+import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin";
+import type { BackgroundTaskManager } from "../features";
+
+const z = tool.schema;
+
+type ToolContext = {
+  sessionID: string;
+  messageID: string;
+  agent: string;
+  abort: AbortSignal;
+};
+
+export function createBackgroundTools(
+  ctx: PluginInput,
+  manager: BackgroundTaskManager
+): Record<string, ToolDefinition> {
+  const background_task = tool({
+    description: `Run agent task. Use sync=true to wait for result, sync=false (default) to run in background.
+
+Agents: explore (codebase grep), librarian (docs/GitHub), oracle (strategy), frontend (UI/UX), document-writer (docs).
+
+Async mode returns task_id immediately - use \`background_output\` to get results.
+Sync mode blocks until completion and returns the result directly.`,
+    args: {
+      description: z.string().describe("Short description of the task (5-10 words)"),
+      prompt: z.string().describe("The task prompt for the agent"),
+      agent: z.string().describe("Agent to use: explore, librarian, oracle, frontend, document-writer"),
+      sync: z.boolean().optional().describe("Wait for completion (default: false = async)"),
+      session_id: z.string().optional().describe("Continue existing session (sync mode only)"),
+    },
+    async execute(args, toolContext) {
+      const tctx = toolContext as ToolContext;
+      const agent = String(args.agent);
+      const prompt = String(args.prompt);
+      const description = String(args.description);
+      const isSync = args.sync === true;
+
+      if (isSync) {
+        return await executeSync(description, prompt, agent, tctx, ctx, args.session_id as string | undefined);
+      }
+
+      const task = await manager.launch({
+        agent,
+        prompt,
+        description,
+        parentSessionId: tctx.sessionID,
+      });
+
+      return `Background task launched.
+
+Task ID: ${task.id}
+Agent: ${agent}
+Status: running
+
+Use \`background_output\` with task_id="${task.id}" to get results.`;
+    },
+  });
+
+  const background_output = tool({
+    description: "Get output from background task.",
+    args: {
+      task_id: z.string().describe("Task ID from background_task"),
+      block: z.boolean().optional().describe("Wait for completion (default: false)"),
+      timeout: z.number().optional().describe("Timeout in ms (default: 120000)"),
+    },
+    async execute(args) {
+      const taskId = String(args.task_id);
+      const block = args.block === true;
+      const timeout = typeof args.timeout === "number" ? args.timeout : 120000;
+
+      const task = await manager.getResult(taskId, block, timeout);
+      if (!task) {
+        return `Task not found: ${taskId}`;
+      }
+
+      const duration = task.completedAt
+        ? `${Math.floor((task.completedAt.getTime() - task.startedAt.getTime()) / 1000)}s`
+        : "running";
+
+      let output = `Task: ${task.id}
+Description: ${task.description}
+Status: ${task.status}
+Duration: ${duration}
+
+---
+
+`;
+
+      if (task.status === "completed" && task.result) {
+        output += task.result;
+      } else if (task.status === "failed") {
+        output += `Error: ${task.error}`;
+      } else {
+        output += "(Task still running)";
+      }
+
+      return output;
+    },
+  });
+
+  const background_cancel = tool({
+    description: "Cancel running background task(s). Use all=true to cancel all.",
+    args: {
+      task_id: z.string().optional().describe("Specific task to cancel"),
+      all: z.boolean().optional().describe("Cancel all running tasks"),
+    },
+    async execute(args) {
+      if (args.all === true) {
+        const count = manager.cancel();
+        return `Cancelled ${count} running task(s).`;
+      }
+
+      if (typeof args.task_id === "string") {
+        const count = manager.cancel(args.task_id);
+        return count > 0 ? `Cancelled task ${args.task_id}.` : `Task ${args.task_id} not found or not running.`;
+      }
+
+      return "Specify task_id or use all=true.";
+    },
+  });
+
+  return { background_task, background_output, background_cancel };
+}
+
+async function executeSync(
+  description: string,
+  prompt: string,
+  agent: string,
+  toolContext: ToolContext,
+  ctx: PluginInput,
+  existingSessionId?: string
+): Promise<string> {
+  let sessionID: string;
+
+  if (existingSessionId) {
+    const sessionResult = await ctx.client.session.get({ path: { id: existingSessionId } });
+    if (sessionResult.error) {
+      return `Error: Failed to get session: ${sessionResult.error}`;
+    }
+    sessionID = existingSessionId;
+  } else {
+    const parentSession = await ctx.client.session.get({ path: { id: toolContext.sessionID } }).catch(() => null);
+    const parentDirectory = parentSession?.data?.directory ?? ctx.directory;
+
+    const createResult = await ctx.client.session.create({
+      body: {
+        parentID: toolContext.sessionID,
+        title: `${description} (@${agent})`,
+      },
+      query: { directory: parentDirectory },
+    });
+
+    if (createResult.error) {
+      return `Error: Failed to create session: ${createResult.error}`;
+    }
+    sessionID = createResult.data.id;
+  }
+
+  // Disable recursive delegation tools to prevent infinite loops
+  try {
+    await ctx.client.session.prompt({
+      path: { id: sessionID },
+      body: {
+        agent,
+        tools: { background_task: false, task: false },
+        parts: [{ type: "text", text: prompt }],
+      },
+    });
+  } catch (error) {
+    return `Error: Failed to send prompt: ${error instanceof Error ? error.message : String(error)}
+
+<task_metadata>
+session_id: ${sessionID}
+</task_metadata>`;
+  }
+
+  const POLL_INTERVAL = 500;
+  const MAX_POLL_TIME = 5 * 60 * 1000;
+  const pollStart = Date.now();
+  let lastMsgCount = 0;
+  let stablePolls = 0;
+
+  while (Date.now() - pollStart < MAX_POLL_TIME) {
+    if (toolContext.abort?.aborted) {
+      return `Task aborted.
+
+<task_metadata>
+session_id: ${sessionID}
+</task_metadata>`;
+    }
+
+    await new Promise((r) => setTimeout(r, POLL_INTERVAL));
+
+    const statusResult = await ctx.client.session.status();
+    const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+    const sessionStatus = allStatuses[sessionID];
+
+    if (sessionStatus && sessionStatus.type !== "idle") {
+      stablePolls = 0;
+      lastMsgCount = 0;
+      continue;
+    }
+
+    const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } });
+    const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>;
+    const currentMsgCount = msgs.length;
+
+    if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
+      stablePolls++;
+      if (stablePolls >= 3) break;
+    } else {
+      stablePolls = 0;
+      lastMsgCount = currentMsgCount;
+    }
+  }
+
+  if (Date.now() - pollStart >= MAX_POLL_TIME) {
+    return `Error: Agent timed out after 5 minutes.
+
+<task_metadata>
+session_id: ${sessionID}
+</task_metadata>`;
+  }
+
+  const messagesResult = await ctx.client.session.messages({ path: { id: sessionID } });
+  if (messagesResult.error) {
+    return `Error: Failed to get messages: ${messagesResult.error}`;
+  }
+
+  const messages = messagesResult.data as Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>;
+  const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
+
+  if (assistantMessages.length === 0) {
+    return `Error: No response from agent.
+
+<task_metadata>
+session_id: ${sessionID}
+</task_metadata>`;
+  }
+
+  const extractedContent: string[] = [];
+  for (const message of assistantMessages) {
+    for (const part of message.parts ?? []) {
+      if ((part.type === "text" || part.type === "reasoning") && part.text) {
+        extractedContent.push(part.text);
+      }
+    }
+  }
+
+  const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
+
+  return `${responseText}
+
+<task_metadata>
+session_id: ${sessionID}
+</task_metadata>`;
+}

+ 1 - 0
src/tools/index.ts

@@ -0,0 +1 @@
+export { createBackgroundTools } from "./background";

+ 17 - 0
tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "declaration": true,
+    "declarationDir": "./dist",
+    "emitDeclarationOnly": true,
+    "strict": true,
+    "skipLibCheck": true,
+    "types": ["bun-types"],
+    "outDir": "./dist",
+    "rootDir": "./src"
+  },
+  "include": ["src/**/*.ts"],
+  "exclude": ["node_modules", "dist", "**/*.test.ts"]
+}