Alvin Unreal 2 months ago
parent
commit
40fd888065
46 changed files with 1023 additions and 1459 deletions
  1. 40 0
      .orchestrator/plans/20260121-codebase-cleanup.md
  2. 0 26
      src/agents/designer.ts
  3. 0 52
      src/agents/explorer.ts
  4. 0 53
      src/agents/fixer.ts
  5. 5 47
      src/agents/index.test.ts
  6. 41 69
      src/agents/index.ts
  7. 0 34
      src/agents/librarian.ts
  8. 0 34
      src/agents/oracle.ts
  9. 3 207
      src/agents/orchestrator.ts
  10. 355 0
      src/agents/prompts.ts
  11. 25 0
      src/agents/types.ts
  12. 0 3
      src/cli/index.ts
  13. 0 1
      src/cli/types.ts
  14. 23 0
      src/config/constants.ts
  15. 0 1
      src/config/schema.ts
  16. 1 1
      src/features/background-manager.test.ts
  17. 48 36
      src/features/background-manager.ts
  18. 6 8
      src/features/tmux-session-manager.ts
  19. 0 87
      src/hooks/auto-update-checker/cache.ts
  20. 157 133
      src/hooks/auto-update-checker/checker.ts
  21. 0 43
      src/hooks/auto-update-checker/constants.ts
  22. 30 59
      src/hooks/auto-update-checker/index.ts
  23. 0 20
      src/hooks/auto-update-checker/types.ts
  24. 8 30
      src/hooks/phase-reminder/index.ts
  25. 4 4
      src/hooks/post-read-nudge/index.ts
  26. 1 1
      src/index.ts
  27. 0 0
      src/shared/agent-variant.test.ts
  28. 1 1
      src/utils/agent-variant.ts
  29. 3 40
      src/tools/shared/downloader-utils.ts
  30. 22 0
      src/shared/formatters.ts
  31. 7 2
      src/shared/index.ts
  32. 0 0
      src/shared/polling.ts
  33. 1 1
      src/utils/tmux.ts
  34. 5 2
      src/tools/ast-grep/downloader.ts
  35. 2 6
      src/tools/ast-grep/utils.ts
  36. 1 2
      src/tools/background.ts
  37. 5 46
      src/tools/grep/cli.ts
  38. 6 11
      src/tools/grep/constants.ts
  39. 1 1
      src/tools/grep/downloader.ts
  40. 2 6
      src/tools/grep/utils.ts
  41. 1 1
      src/tools/lsp/client.ts
  42. 0 185
      src/tools/quota/api.ts
  43. 0 50
      src/tools/quota/command.ts
  44. 219 104
      src/tools/quota/index.ts
  45. 0 49
      src/tools/quota/types.ts
  46. 0 3
      src/utils/index.ts

+ 40 - 0
.orchestrator/plans/20260121-codebase-cleanup.md

@@ -0,0 +1,40 @@
+# Codebase Cleanup and Simplification Plan (v2) - 2026-01-21
+
+## Goal
+Simplify the `oh-my-opencode-slim` codebase by applying coding standards simplification and YAGNI to the *implementation logic*. All existing features (auto-update, hooks, background tasks, tmux) must be preserved but with cleaner, consolidated, and more maintainable code.
+
+## Constraints
+- **Preserve all features**: Do not remove any functional capabilities.
+- **Maintain interface**: Keep the existing plugin structure and external APIs.
+- **Standardization**: Consolidate types, constants, and redundant utility logic.
+
+## Stage 1: Type Consolidation & Agent Refactoring
+**Goal**: Resolve circular dependencies and simplify the agent registration system.
+- [ ] **1.1 Move Types**: Centralize `AgentDefinition`, `AgentName`, and other core types to `src/agents/types.ts`.
+- [ ] **1.2 Data-Driven Agents**: Replace individual agent factory functions (`createExplorerAgent`, etc.) with a single generic factory and a configuration map in `src/agents/index.ts`.
+- [ ] **1.3 Prune Agent Logic**: Remove `AGENT_ALIASES` (legacy) and the `fixer` inheritance hack.
+- [ ] **1.4 Dynamic Orchestrator Prompt**: Update `ORCHESTRATOR_PROMPT` to derive subagent capabilities from the central config.
+- [ ] **1.5 Verify Stage**: Ensure `npm run build` passes and all agents are correctly registered in the plugin. (@orchestrator)
+
+## Stage 2: Feature & Hook Consolidation
+**Goal**: Reduce file fragmentation and optimize internal logic without losing features.
+- [ ] **2.1 Consolidate Auto-Update**: Merge the 5-file `auto-update-checker` structure into 2 files (`index.ts` and `checker.ts`).
+- [ ] **2.2 Centralize Hooks Logic**: Move hardcoded prompts from `phase-reminder` and `post-read-nudge` to `src/config/constants.ts` and simplify their message-parsing logic.
+- [ ] **2.3 Optimize Background Polling**: Refactor `BackgroundManager` to use batched session status checks and a Promise-based waiter mechanism for results.
+- [ ] **2.4 Clean up Tmux Logic**: Unify constants in `TmuxSessionManager` and align its polling logic with the improved `BackgroundManager` patterns.
+- [ ] **2.5 Verify Stage**: Test that auto-update check still runs and background tasks still report results. (@orchestrator)
+
+## Stage 3: Tool & Utility Unification
+**Goal**: Remove duplication across tools and consolidate the utility folder structure.
+- [ ] **3.1 Unified Binary Downloader**: Create a generic `BinaryDownloader` in `src/shared/` to be used by both `grep` and `ast-grep`.
+- [ ] **3.2 Shared Formatters**: Extract common search result formatting (grouping by file) into `src/shared/formatters.ts`.
+- [ ] **3.3 Simplify Grep CLI**: Remove the redundant system `grep` fallback, relying solely on `ripgrep`.
+- [ ] **3.4 Merge Shared/Utils**: Combine `src/utils/`, `src/tools/shared/`, and `src/shared/` into a single `src/shared/` directory.
+- [ ] **3.5 Verify Stage**: Verify `grep` and `ast-grep` tools still function correctly. (@orchestrator)
+
+## Stage 4: Structural De-fragmentation
+**Goal**: Collapse small files and finalize the "slim" architecture.
+- [ ] **4.1 Collapse Simple Tools**: Merge `index.ts`, `types.ts`, `constants.ts` for simple tools (like `quota`) into single files.
+- [ ] **4.2 Prune Config Schema**: Remove unused fields (variant, temperature, etc.) from `src/config/schema.ts` that aren't utilized by the core logic.
+- [ ] **4.3 Final YAGNI Sweep**: Scan for any remaining dead code or over-engineered abstractions.
+- [ ] **4.4 Verify Stage**: Comprehensive final verification of all plugin features and tools. (@orchestrator)

+ 0 - 26
src/agents/designer.ts

@@ -1,26 +0,0 @@
-import type { AgentDefinition } from "./orchestrator";
-
-export function createDesignerAgent(model: string): AgentDefinition {
-  return {
-    name: "designer",
-    description: "UI/UX design and implementation. Use for styling, responsive design, component architecture and visual polish.",
-    config: {
-      model,
-      temperature: 0.7,
-      prompt: DESIGNER_PROMPT,
-    },
-  };
-}
-
-const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX engineer.
-
-**Role**: Craft stunning UI/UX even without design mockups.
-
-**Design Principles**:
-- Rich aesthetics that wow at first glance
-- Mobile-first responsive design
-
-**Constraints**:
-- Match existing design system if present
-- Use existing component libraries when available
-- Prioritize visual excellence over code perfection`;

+ 0 - 52
src/agents/explorer.ts

@@ -1,52 +0,0 @@
-import type { AgentDefinition } from "./orchestrator";
-
-export function createExplorerAgent(model: string): AgentDefinition {
-  return {
-    name: "explorer",
-    description: "Fast codebase search and pattern matching. Use for finding files, locating code patterns, and answering 'where is X?' questions.",
-    config: {
-      model,
-      temperature: 0.1,
-      prompt: EXPLORER_PROMPT,
-    },
-  };
-}
-
-const EXPLORER_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".
-
-**Tools Available**:
-- **grep**: Fast regex content search (powered by ripgrep). Use for text patterns, function names, strings.
-  Example: grep(pattern="function handleClick", include="*.ts")
-- **glob**: File pattern matching. Use to find files by name/extension.
-- **ast_grep_search**: AST-aware structural search (25 languages). Use for code patterns.
-  - Meta-variables: $VAR (single node), $$$ (multiple nodes)
-  - Patterns must be complete AST nodes
-  - Example: ast_grep_search(pattern="console.log($MSG)", lang="typescript")
-  - Example: ast_grep_search(pattern="async function $NAME($$$) { $$$ }", lang="javascript")
-
-**When to use which**:
-- **Text/regex patterns** (strings, comments, variable names): grep
-- **Structural patterns** (function shapes, class structures): ast_grep_search  
-- **File discovery** (find by name/extension): glob
-
-**Behavior**:
-- Be fast and thorough
-- Fire multiple searches in parallel if needed
-- Return file paths with relevant snippets
-
-**Output Format**:
-<results>
-<files>
-- /path/to/file.ts:42 - 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`;

+ 0 - 53
src/agents/fixer.ts

@@ -1,53 +0,0 @@
-import type { AgentDefinition } from "./orchestrator";
-
-export function createFixerAgent(model: string): AgentDefinition {
-  return {
-    name: "fixer",
-    description: "Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.",
-    config: {
-      model,
-      temperature: 0.2,
-      prompt: FIXER_PROMPT,
-    },
-  };
-}
-
-const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
-
-**Role**: Execute code changes efficiently. You receive complete context from research agents and clear task specifications from the Orchestrator. Your job is to implement, not plan or research.
-
-**Behavior**:
-- Execute the task specification provided by the Orchestrator
-- Use the research context (file paths, documentation, patterns) provided
-- Read files before using edit/write tools and gather exact content before making changes
-- Be fast and direct - no research, no delegation, No multi-step research/planning; minimal execution sequence ok
-- Run tests/lsp_diagnostics when relevant or requested (otherwise note as skipped with reason)
-- Report completion with summary of changes
-
-**Constraints**:
-- NO external research (no websearch, context7, grep_app)
-- NO delegation (no background_task)
-- No multi-step research/planning; minimal execution sequence ok
-- If context is insufficient, read the files listed; only ask for missing inputs you cannot retrieve
-
-**Output Format**:
-<summary>
-Brief summary of what was implemented
-</summary>
-<changes>
-- file1.ts: Changed X to Y
-- file2.ts: Added Z function
-</changes>
-<verification>
-- Tests passed: [yes/no/skip reason]
-- LSP diagnostics: [clean/errors found/skip reason]
-</verification>
-
-Use the following when no code changes were made:
-<summary>
-No changes required
-</summary>
-<verification>
-- Tests passed: [not run - reason]
-- LSP diagnostics: [not run - reason]
-</verification>`;

+ 5 - 47
src/agents/index.test.ts

@@ -1,44 +1,8 @@
 import { describe, expect, test } from "bun:test";
-import { createAgents, getAgentConfigs, getSubagentNames, getPrimaryAgentNames } from "./index";
+import { createAgents, getAgentConfigs, getSubagentNames } from "./index";
 import type { PluginConfig } from "../config";
 
-describe("agent alias backward compatibility", () => {
-  test("applies 'explore' config to 'explorer' agent", () => {
-    const config: PluginConfig = {
-      agents: {
-        explore: { model: "test/old-explore-model" },
-      },
-    };
-    const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer).toBeDefined();
-    expect(explorer!.config.model).toBe("test/old-explore-model");
-  });
-
-  test("applies 'frontend-ui-ux-engineer' config to 'designer' agent", () => {
-    const config: PluginConfig = {
-      agents: {
-        "frontend-ui-ux-engineer": { model: "test/old-frontend-model" },
-      },
-    };
-    const agents = createAgents(config);
-    const designer = agents.find((a) => a.name === "designer");
-    expect(designer).toBeDefined();
-    expect(designer!.config.model).toBe("test/old-frontend-model");
-  });
-
-  test("new name takes priority over old alias", () => {
-    const config: PluginConfig = {
-      agents: {
-        explore: { model: "old-model" },
-        explorer: { model: "new-model" },
-      },
-    };
-    const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer!.config.model).toBe("new-model");
-  });
-
+describe("agent overrides", () => {
   test("new agent names work directly", () => {
     const config: PluginConfig = {
       agents: {
@@ -51,24 +15,18 @@ describe("agent alias backward compatibility", () => {
     expect(agents.find((a) => a.name === "designer")!.config.model).toBe("direct-designer");
   });
 
-  test("temperature override via old alias", () => {
+  test("prompt override works", () => {
     const config: PluginConfig = {
       agents: {
-        explore: { temperature: 0.5 },
+        explorer: { prompt: "custom prompt" },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer!.config.temperature).toBe(0.5);
+    expect(agents.find((a) => a.name === "explorer")!.config.prompt).toBe("custom prompt");
   });
 });
 
 describe("agent classification", () => {
-  test("getPrimaryAgentNames returns only orchestrator", () => {
-    const names = getPrimaryAgentNames();
-    expect(names).toEqual(["orchestrator"]);
-  });
-
   test("getSubagentNames excludes orchestrator", () => {
     const names = getSubagentNames();
     expect(names).not.toContain("orchestrator");

+ 41 - 69
src/agents/index.ts

@@ -1,25 +1,21 @@
 import type { AgentConfig as SDKAgentConfig } from "@opencode-ai/sdk";
 import { DEFAULT_MODELS, type PluginConfig, type AgentOverrideConfig } from "../config";
-import { createOrchestratorAgent, type AgentDefinition } from "./orchestrator";
-import { createOracleAgent } from "./oracle";
-import { createLibrarianAgent } from "./librarian";
-import { createExplorerAgent } from "./explorer";
-import { createDesignerAgent } from "./designer";
-import { createFixerAgent } from "./fixer";
-
-export type { AgentDefinition } from "./orchestrator";
-
-type AgentFactory = (model: string) => AgentDefinition;
-
-/** Map old agent names to new names for backward compatibility */
-const AGENT_ALIASES: Record<string, string> = {
-  "explore": "explorer",
-  "frontend-ui-ux-engineer": "designer",
-};
-
-function getOverride(overrides: Record<string, AgentOverrideConfig>, name: string): AgentOverrideConfig | undefined {
-  return overrides[name] ?? overrides[Object.keys(AGENT_ALIASES).find(k => AGENT_ALIASES[k] === name) ?? ""];
-}
+import { createOrchestratorAgent } from "./orchestrator";
+import { 
+  AgentDefinition, 
+  SubagentName, 
+  SUBAGENT_NAMES, 
+  isSubagent,
+  getSubagentNames
+} from "./types";
+export { 
+  AgentDefinition, 
+  SubagentName, 
+  SUBAGENT_NAMES, 
+  isSubagent,
+  getSubagentNames
+} from "./types";
+import * as prompts from "./prompts";
 
 function applyOverrides(agent: AgentDefinition, override: AgentOverrideConfig): void {
   if (override.model) agent.config.model = override.model;
@@ -37,74 +33,50 @@ function applyDefaultPermissions(agent: AgentDefinition): void {
   agent.config.permission = { ...existing, question: "allow" } as SDKAgentConfig["permission"];
 }
 
-/** Constants for agent classification */
-export const PRIMARY_AGENT_NAMES = ["orchestrator"] as const;
-export type PrimaryAgentName = (typeof PRIMARY_AGENT_NAMES)[number];
-
-export const SUBAGENT_NAMES = ["explorer", "librarian", "oracle", "designer", "fixer"] as const;
-export type SubagentName = (typeof SUBAGENT_NAMES)[number];
-
-export function getPrimaryAgentNames(): PrimaryAgentName[] {
-  return [...PRIMARY_AGENT_NAMES];
-}
-
-export function getSubagentNames(): SubagentName[] {
+/** Get list of agent names */
+export function getAgentNames(): SubagentName[] {
   return [...SUBAGENT_NAMES];
 }
 
-export function isSubagent(name: string): name is SubagentName {
-  return (SUBAGENT_NAMES as readonly string[]).includes(name);
-}
-
-/** Agent factories indexed by name */
-const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
-  explorer: createExplorerAgent,
-  librarian: createLibrarianAgent,
-  oracle: createOracleAgent,
-  designer: createDesignerAgent,
-  fixer: createFixerAgent,
-};
-
-/** Get list of agent names */
-export function getAgentNames(): SubagentName[] {
-  return getSubagentNames();
+/** generic factory for subagents */
+function createSubagent(name: SubagentName, model: string): AgentDefinition {
+  const promptKey = `${name.toUpperCase()}_PROMPT` as keyof typeof prompts;
+  const descriptionKey = `${name.toUpperCase()}_DESCRIPTION` as keyof typeof prompts;
+  
+  return {
+    name,
+    description: prompts[descriptionKey] as string,
+    config: {
+      model,
+      temperature: name === "designer" ? 0.7 : 0.1,
+      prompt: prompts[promptKey] as string,
+    },
+  };
 }
 
 export function createAgents(config?: PluginConfig): AgentDefinition[] {
   const disabledAgents = new Set(config?.disabled_agents ?? []);
   const agentOverrides = config?.agents ?? {};
 
-  // TEMP: If fixer has no config, inherit from librarian's model to avoid breaking
-  // existing users who don't have fixer in their config yet
-  const getModelForAgent = (name: SubagentName): string => {
-    if (name === "fixer" && !getOverride(agentOverrides, "fixer")?.model) {
-      return getOverride(agentOverrides, "librarian")?.model ?? DEFAULT_MODELS["librarian"];
-    }
-    return DEFAULT_MODELS[name];
-  };
-
   // 1. Gather all sub-agent proto-definitions
-  const protoSubAgents = (Object.entries(SUBAGENT_FACTORIES) as [SubagentName, AgentFactory][]).map(
-    ([name, factory]) => factory(getModelForAgent(name))
-  );
-
-  // 2. Apply common filtering and overrides
-  const allSubAgents = protoSubAgents
-    .filter((a) => !disabledAgents.has(a.name))
-    .map((agent) => {
-      const override = getOverride(agentOverrides, agent.name);
+  const allSubAgents = SUBAGENT_NAMES
+    .filter(name => !disabledAgents.has(name))
+    .map(name => {
+      const override = agentOverrides[name];
+      const model = override?.model ?? DEFAULT_MODELS[name];
+      const agent = createSubagent(name, model);
       if (override) {
         applyOverrides(agent, override);
       }
       return agent;
     });
 
-  // 3. Create Orchestrator (with its own overrides)
+  // 2. Create Orchestrator (with its own overrides)
   const orchestratorModel =
-    getOverride(agentOverrides, "orchestrator")?.model ?? DEFAULT_MODELS["orchestrator"];
+    agentOverrides["orchestrator"]?.model ?? DEFAULT_MODELS["orchestrator"];
   const orchestrator = createOrchestratorAgent(orchestratorModel);
   applyDefaultPermissions(orchestrator);
-  const oOverride = getOverride(agentOverrides, "orchestrator");
+  const oOverride = agentOverrides["orchestrator"];
   if (oOverride) {
     applyOverrides(orchestrator, oOverride);
   }

+ 0 - 34
src/agents/librarian.ts

@@ -1,34 +0,0 @@
-import type { AgentDefinition } from "./orchestrator";
-
-export function createLibrarianAgent(model: string): AgentDefinition {
-  return {
-    name: "librarian",
-    description: "External documentation and library research. Use for official docs lookup, GitHub examples, and understanding library internals.",
-    config: {
-      model,
-      temperature: 0.1,
-      prompt: 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`;

+ 0 - 34
src/agents/oracle.ts

@@ -1,34 +0,0 @@
-import type { AgentDefinition } from "./orchestrator";
-
-export function createOracleAgent(model: string): AgentDefinition {
-  return {
-    name: "oracle",
-    description: "Strategic technical advisor. Use for architecture decisions, complex debugging, code review, and engineering guidance.",
-    config: {
-      model,
-      temperature: 0.1,
-      prompt: 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`;

+ 3 - 207
src/agents/orchestrator.ts

@@ -1,10 +1,5 @@
-import type { AgentConfig } from "@opencode-ai/sdk";
-
-export interface AgentDefinition {
-  name: string;
-  description?: string;
-  config: AgentConfig;
-}
+import { getOrchestratorPrompt } from "./prompts";
+import type { AgentDefinition } from "./types";
 
 export function createOrchestratorAgent(model: string): AgentDefinition {
   return {
@@ -12,207 +7,8 @@ export function createOrchestratorAgent(model: string): AgentDefinition {
     config: {
       model,
       temperature: 0.1,
-      prompt: ORCHESTRATOR_PROMPT,
+      prompt: getOrchestratorPrompt(),
     },
   };
 }
 
-const ORCHESTRATOR_PROMPT = `<Role>
-You are an AI coding orchestrator.
-
-**You are excellent in finding the best path towards achieving user's goals while optimizing speed, reliability, quality and cost.**
-**You are excellent in utilizing parallel background tasks and flow wisely for increased efficiency.**
-**You are excellent choosing the right order of actions to maximize quality, reliability, speed and cost.**
-
-</Role>
-
-<Agents>
-
-@explorer
-- Role: Rapid repo search specialist with unuque set of tools
-- Capabilities: Uses glob, grep, and AST queries to map files, symbols, and patterns quickly
-- Tools/Constraints: Read-only reporting so others act on the findings
-- Triggers: "find", "where is", "search for", "which file", "locate"
-- Delegate to @explorer when you need things such as:
-  * locate the right file or definition
-  * understand repo structure before editing
-  * map symbol usage or references
-  * gather code context before coding
-
-@librarian
-- Role: Documentation and library research expert
-- Capabilities: Pulls official docs and real-world examples, summarizes APIs, best practices, and caveats
-- Tools/Constraints: Read-only knowledge retrieval that feeds other agents
-- Triggers: "how does X library work", "docs for", "API reference", "best practice for"
-- Delegate to @librarian when you need things such as:
-  * up-to-date documentation
-  * API clarification
-  * official examples or usage guidance
-  * library-specific best practices
-  * dependency version caveats
-
-@oracle
-- About: Orchestrator should not make high-risk architecture calls alone; oracle validates direction
-- Role: Architecture, debugging, and strategic reviewer
-- Capabilities: Evaluates trade-offs, spots system-level issues, frames debugging steps before large moves
-- Tools/Constraints: Advisory only; no direct code changes
-- Triggers: "should I", "why does", "review", "debug", "what's wrong", "tradeoffs"
-- Delegate to @oracle when you need things such as:
-  * architectural uncertainty resolved
-  * system-level trade-offs evaluated
-  * debugging guidance for complex issues
-  * verification of long-term reliability or safety
-  * risky refactors assessed
-
-@designer
-- Role: UI/UX design leader
-- Capabilities: Shapes visual direction, interactions, and responsive polish for intentional experiences
-- Tools/Constraints: Executes aesthetic frontend work with design-first intent
-- Triggers: "styling", "responsive", "UI", "UX", "component design", "CSS", "animation"
-- Delegate to @designer when you need things such as:
-  * visual or interaction strategy
-  * responsive styling and polish
-  * thoughtful component layouts
-  * animation or transition storyboarding
-  * intentional typography/color direction
-
-@fixer
-- Role: Fast, cost-effective implementation specialist
-- Capabilities: Executes concrete plans efficiently once context and spec are solid
-- Tools/Constraints: Execution only; no research or delegation
-- Triggers: "implement", "refactor", "update", "change", "add feature", "fix bug"
-- Delegate to @fixer when you need things such as:
-  * concrete changes from a full spec
-  * rapid refactors with well-understood impact
-  * feature updates once design and plan are approved
-  * safe bug fixes with clear reproduction
-  * implementation of pre-populated plans
-
-</Agents>
-
-
-<Workflow>
-# Orchestrator Workflow Guide
-
-## Phase 1: Understand
-Parse the request thoroughly. Identify both explicit requirements and implicit needs.
-
----
-
-## Phase 2: Best Path Analysis
-For the given goal, determine the optimal approach by evaluating:
-- **Quality**: Will this produce the best possible outcome?
-- **Speed**: What's the fastest path without sacrificing quality?
-- **Cost**: Are we being token-efficient?
-- **Reliability**: Will this approach be robust and maintainable?
-
----
-
-## Phase 3: Delegation Gate (MANDATORY - DO NOT SKIP)
-**STOP.** Before ANY implementation, review agent delegation rules and select the best specialist(s).
-
-### Why Delegation Matters
-Each specialist delivers 10x better results in their domain:
-- **@designer** → Superior UI/UX designs you can't match → **improves quality**
-- **@librarian** → Finds documentation and references you'd miss → **improves speed + quality**
-- **@explorer** → Searches and researches faster than you → **improves speed**
-- **@oracle** → Catches architectural issues you'd overlook → **improves quality + reliability**
-- **@fixer** → Executes pre-planned implementations faster → **improves speed + cost**
-
-### Delegation Best Practices
-When delegating tasks:
-- **Use file paths/line references, NOT file contents**: Reference like \`"see src/components/Header.ts:42-58"\` instead of pasting entire files
-- **Provide context, not dumps**: Summarize what's relevant from research; let specialists read what they need
-- **Token efficiency**: Large content pastes waste tokens, degrade performance, and can hit context limits
-- **Clear instructions**: Give specialists specific objectives and success criteria
-- **Let user know**: Before each delegation let user know very briefly about the delegation goal and reason
-
-### Fixer-Orchestrator Relationship
-The Orchestrator is intelligent enough to understand when delegating to Fixer is
-inefficient. If a task is simple enough that the overhead of creating context
-and delegating would equal or exceed the actual implementation effort, the
-Orchestrator handles it directly.
-
-The Orchestrator leverages Fixer's ability to spawn in parallel, which
-accelerates progress toward its ultimate goal while maintaining control over the
-execution plan and path.
-
-**Key Principles:**
-- **Cost-benefit analysis**: Delegation only occurs when it provides net efficiency gains
-- **Parallel execution**: Multiple Fixer instances can run simultaneously for independent tasks
-- **Centralized control**: Orchestrator maintains oversight of the overall execution strategy
-- **Smart task routing**: Simple tasks are handled directly; complex or parallelizable tasks are delegated
-
----
-
-## Phase 4: Parallelization Strategy
-Before executing, ask yourself: should the task split into subtasks and scheduled in parallel?
-- Can independent research tasks run simultaneously? (e.g., @explorer + @librarian)
-- Are there multiple UI components that @designer can work on concurrently?
-- Can @fixer handle multiple isolated implementation tasks at once?
-- Multiple @explorer instances for different search domains?
-- etc
-
-### Balance considerations:
-- Consider task dependencies: what MUST finish before other tasks can start?
-
----
-
-## Phase 5: Plan & Execute
-1. **Create todo lists** as needed (break down complex tasks)
-2. **Fire background research** (@explorer, @librarian) in parallel as needed
-3. **Delegate implementation** to specialists based on Phase 3 checklist
-4. **Only do work yourself** if NO specialist applies
-5. **Integrate results** from specialists
-6. **Monitor progress** and adjust strategy if needed
-
----
-
-## Phase 6: Verify
-- Run \`lsp_diagnostics\` to check for errors
-- Suggest user run \`yagni-enforcement\` skill when applicable
-- Verify all delegated tasks completed successfully
-- Confirm the solution meets original requirements (Phase 1)
-
----
-
-## Quick Decision Matrix
-
-| Scenario | Best Agent(s) | Run in Parallel? |
-|----------|---------------|------------------|
-| Need UI mockup | @designer | N/A |
-| Need API docs + code examples | @librarian + @explorer | ✅ Yes |
-| Multiple independent bug fixes | @fixer (multiple instances) | ✅ Yes |
-| Architecture review before build | @oracle → then @designer/@fixer | ❌ No (sequential) |
-| Research topic + find similar projects | @explorer (multiple instances) | ✅ Yes |
-| Complex refactor with dependencies | @oracle → @fixer | ❌ No (sequential) |
-
----
-
-## Remember
-**You are the conductor, not the musician.** Your job is to orchestrate specialists efficiently, not to do their specialized work. When in doubt: delegate.
-</Workflow>
-
-## Communication Style
-
-### Be Concise
-- Answer directly without preamble
-- Don't summarize what you did unless asked
-- Don't explain your code unless asked
-- One word answers are acceptable when appropriate
-
-### No Flattery
-Never start responses with:
-- "Great question!"
-- "That's a really good idea!"
-- "Excellent choice!"
-- Any praise of the user's input
-
-### When User is Wrong
-If the user's approach seems problematic:
-- Don't blindly implement it
-- Don't lecture or be preachy
-- Concisely state your concern and alternative
-- Ask if they want to proceed anyway
-
-`;

+ 355 - 0
src/agents/prompts.ts

@@ -0,0 +1,355 @@
+export const SUBAGENT_SPECS: Record<string, { role: string, capabilities: string, triggers: string, delegateTasks: string[] }> = {
+  explorer: {
+    role: "Rapid repo search specialist with unuque set of tools",
+    capabilities: "Uses glob, grep, and AST queries to map files, symbols, and patterns quickly",
+    triggers: '"find", "where is", "search for", "which file", "locate"',
+    delegateTasks: [
+      "locate the right file or definition",
+      "understand repo structure before editing",
+      "map symbol usage or references",
+      "gather code context before coding"
+    ]
+  },
+  librarian: {
+    role: "Documentation and library research expert",
+    capabilities: "Pulls official docs and real-world examples, summarizes APIs, best practices, and caveats",
+    triggers: '"how does X library work", "docs for", "API reference", "best practice for"',
+    delegateTasks: [
+      "up-to-date documentation",
+      "API clarification",
+      "official examples or usage guidance",
+      "library-specific best practices",
+      "dependency version caveats"
+    ]
+  },
+  oracle: {
+    role: "Architecture, debugging, and strategic reviewer",
+    capabilities: "Evaluates trade-offs, spots system-level issues, frames debugging steps before large moves",
+    triggers: '"should I", "why does", "review", "debug", "what\'s wrong", "tradeoffs"',
+    delegateTasks: [
+      "architectural uncertainty resolved",
+      "system-level trade-offs evaluated",
+      "debugging guidance for complex issues",
+      "verification of long-term reliability or safety",
+      "risky refactors assessed"
+    ]
+  },
+  designer: {
+    role: "UI/UX design leader",
+    capabilities: "Shapes visual direction, interactions, and responsive polish for intentional experiences",
+    triggers: '"styling", "responsive", "UI", "UX", "component design", "CSS", "animation"',
+    delegateTasks: [
+      "visual or interaction strategy",
+      "responsive styling and polish",
+      "thoughtful component layouts",
+      "animation or transition storyboarding",
+      "intentional typography/color direction"
+    ]
+  },
+  fixer: {
+    role: "Fast, cost-effective implementation specialist",
+    capabilities: "Executes concrete plans efficiently once context and spec are solid",
+    triggers: '"implement", "refactor", "update", "change", "add feature", "fix bug"',
+    delegateTasks: [
+      "concrete changes from a full spec",
+      "rapid refactors with well-understood impact",
+      "feature updates once design and plan are approved",
+      "safe bug fixes with clear reproduction",
+      "implementation of pre-populated plans"
+    ]
+  }
+};
+
+export function getOrchestratorPrompt() {
+  const agentsSection = Object.entries(SUBAGENT_SPECS).map(([name, spec]) => {
+    const tasks = spec.delegateTasks.map(task => `  * ${task}`).join("\n");
+    return `@${name}
+- Role: ${spec.role}
+- Capabilities: ${spec.capabilities}
+- Tools/Constraints: ${name === "fixer" ? "Execution only; no research or delegation" : "Read-only reporting so others act on the findings"}
+- Triggers: ${spec.triggers}
+- Delegate to @${name} when you need things such as:
+${tasks}`;
+  }).join("\n\n");
+
+  return `<Role>
+You are an AI coding orchestrator.
+
+**You are excellent in finding the best path towards achieving user's goals while optimizing speed, reliability, quality and cost.**
+**You are excellent in utilizing parallel background tasks and flow wisely for increased efficiency.**
+**You are excellent choosing the right order of actions to maximize quality, reliability, speed and cost.**
+
+</Role>
+
+<Agents>
+
+${agentsSection}
+
+</Agents>
+
+
+<Workflow>
+# Orchestrator Workflow Guide
+
+## Phase 1: Understand
+Parse the request thoroughly. Identify both explicit requirements and implicit needs.
+
+---
+
+## Phase 2: Best Path Analysis
+For the given goal, determine the optimal approach by evaluating:
+- **Quality**: Will this produce the best possible outcome?
+- **Speed**: What's the fastest path without sacrificing quality?
+- **Cost**: Are we being token-efficient?
+- **Reliability**: Will this approach be robust and maintainable?
+
+---
+
+## Phase 3: Delegation Gate (MANDATORY - DO NOT SKIP)
+**STOP.** Before ANY implementation, review agent delegation rules and select the best specialist(s).
+
+### Why Delegation Matters
+Each specialist delivers 10x better results in their domain:
+- **@designer** → Superior UI/UX designs you can't match → **improves quality**
+- **@librarian** → Finds documentation and references you'd miss → **improves speed + quality**
+- **@explorer** → Searches and researches faster than you → **improves speed**
+- **@oracle** → Catches architectural issues you'd overlook → **improves quality + reliability**
+- **@fixer** → Executes pre-planned implementations faster → **improves speed + cost**
+
+### Delegation Best Practices
+When delegating tasks:
+- **Use file paths/line references, NOT file contents**: Reference like \`"see src/components/Header.ts:42-58"\` instead of pasting entire files
+- **Provide context, not dumps**: Summarize what's relevant from research; let specialists read what they need
+- **Token efficiency**: Large content pastes waste tokens, degrade performance, and can hit context limits
+- **Clear instructions**: Give specialists specific objectives and success criteria
+- **Let user know**: Before each delegation let user know very briefly about the delegation goal and reason
+
+### Fixer-Orchestrator Relationship
+The Orchestrator is intelligent enough to understand when delegating to Fixer is
+inefficient. If a task is simple enough that the overhead of creating context
+and delegating would equal or exceed the actual implementation effort, the
+Orchestrator handles it directly.
+
+The Orchestrator leverages Fixer's ability to spawn in parallel, which
+accelerates progress toward its ultimate goal while maintaining control over the
+execution plan and path.
+
+**Key Principles:**
+- **Cost-benefit analysis**: Delegation only occurs when it provides net efficiency gains
+- **Parallel execution**: Multiple Fixer instances can run simultaneously for independent tasks
+- **Centralized control**: Orchestrator maintains oversight of the overall execution strategy
+- **Smart task routing**: Simple tasks are handled directly; complex or parallelizable tasks are delegated
+
+---
+
+## Phase 4: Parallelization Strategy
+Before executing, ask yourself: should the task split into subtasks and scheduled in parallel?
+- Can independent research tasks run simultaneously? (e.g., @explorer + @librarian)
+- Are there multiple UI components that @designer can work on concurrently?
+- Can @fixer handle multiple isolated implementation tasks at once?
+- Multiple @explorer instances for different search domains?
+- etc
+
+### Balance considerations:
+- Consider task dependencies: what MUST finish before other tasks can start?
+
+---
+
+## Phase 5: Plan & Execute
+1. **Create todo lists** as needed (break down complex tasks)
+2. **Fire background research** (@explorer, @librarian) in parallel as needed
+3. **Delegate implementation** to specialists based on Phase 3 checklist
+4. **Only do work yourself** if NO specialist applies
+5. **Integrate results** from specialists
+6. **Monitor progress** and adjust strategy if needed
+
+---
+
+## Phase 6: Verify
+- Run \`lsp_diagnostics\` to check for errors
+- Suggest user run \`yagni-enforcement\` skill when applicable
+- Verify all delegated tasks completed successfully
+- Confirm the solution meets original requirements (Phase 1)
+
+---
+
+## Quick Decision Matrix
+
+| Scenario | Best Agent(s) | Run in Parallel? |
+|----------|---------------|------------------|
+| Need UI mockup | @designer | N/A |
+| Need API docs + code examples | @librarian + @explorer | ✅ Yes |
+| Multiple independent bug fixes | @fixer (multiple instances) | ✅ Yes |
+| Architecture review before build | @oracle → then @designer/@fixer | ❌ No (sequential) |
+| Research topic + find similar projects | @explorer (multiple instances) | ✅ Yes |
+| Complex refactor with dependencies | @oracle → @fixer | ❌ No (sequential) |
+
+---
+
+## Remember
+**You are the conductor, not the musician.** Your job is to orchestrate specialists efficiently, not to do their specialized work. When in doubt: delegate.
+</Workflow>
+
+## Communication Style
+
+### Be Concise
+- Answer directly without preamble
+- Don't summarize what you did unless asked
+- Don't explain your code unless asked
+- One word answers are acceptable when appropriate
+
+### No Flattery
+Never start responses with:
+- "Great question!"
+- "That's a really good idea!"
+- "Excellent choice!"
+- Any praise of the user's input
+
+### When User is Wrong
+If the user's approach seems problematic:
+- Don't blindly implement it
+- Don't lecture or be preachy
+- Concisely state your concern and alternative
+- Ask if they want to proceed anyway
+
+`;
+}
+
+export const EXPLORER_DESCRIPTION = "Fast codebase search and pattern matching. Use for finding files, locating code patterns, and answering 'where is X?' questions.";
+export const EXPLORER_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".
+
+**Tools Available**:
+- **grep**: Fast regex content search (powered by ripgrep). Use for text patterns, function names, strings.
+  Example: grep(pattern="function handleClick", include="*.ts")
+- **glob**: File pattern matching. Use to find files by name/extension.
+- **ast_grep_search**: AST-aware structural search (25 languages). Use for code patterns.
+  - Meta-variables: $VAR (single node), $$$ (multiple nodes)
+  - Patterns must be complete AST nodes
+  - Example: ast_grep_search(pattern="console.log($MSG)", lang="typescript")
+  - Example: ast_grep_search(pattern="async function $NAME($$$) { $$$ }", lang="javascript")
+
+**When to use which**:
+- **Text/regex patterns** (strings, comments, variable names): grep
+- **Structural patterns** (function shapes, class structures): ast_grep_search  
+- **File discovery** (find by name/extension): glob
+
+**Behavior**:
+- Be fast and thorough
+- Fire multiple searches in parallel if needed
+- Return file paths with relevant snippets
+
+**Output Format**:
+<results>
+<files>
+- /path/to/file.ts:42 - 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`;
+
+export const LIBRARIAN_DESCRIPTION = "External documentation and library research. Use for official docs lookup, GitHub examples, and understanding library internals.";
+export 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`;
+
+export const ORACLE_DESCRIPTION = "Strategic technical advisor. Use for architecture decisions, complex debugging, code review, and engineering guidance.";
+export 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`;
+
+export const DESIGNER_DESCRIPTION = "UI/UX design and implementation. Use for styling, responsive design, component architecture and visual polish.";
+export const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX engineer.
+
+**Role**: Craft stunning UI/UX even without design mockups.
+
+**Design Principles**:
+- Rich aesthetics that wow at first glance
+- Mobile-first responsive design
+
+**Constraints**:
+- Match existing design system if present
+- Use existing component libraries when available
+- Prioritize visual excellence over code perfection`;
+
+export const FIXER_DESCRIPTION = "Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.";
+export const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
+
+**Role**: Execute code changes efficiently. You receive complete context from research agents and clear task specifications from the Orchestrator. Your job is to implement, not plan or research.
+
+**Behavior**:
+- Execute the task specification provided by the Orchestrator
+- Use the research context (file paths, documentation, patterns) provided
+- Read files before using edit/write tools and gather exact content before making changes
+- Be fast and direct - no research, no delegation, No multi-step research/planning; minimal execution sequence ok
+- Run tests/lsp_diagnostics when relevant or requested (otherwise note as skipped with reason)
+- Report completion with summary of changes
+
+**Constraints**:
+- NO external research (no websearch, context7, grep_app)
+- NO delegation (no background_task)
+- No multi-step research/planning; minimal execution sequence ok
+- If context is insufficient, read the files listed; only ask for missing inputs you cannot retrieve
+
+**Output Format**:
+<summary>
+Brief summary of what was implemented
+</summary>
+<changes>
+- file1.ts: Changed X to Y
+- file2.ts: Added Z function
+</changes>
+<verification>
+- Tests passed: [yes/no/skip reason]
+- LSP diagnostics: [clean/errors found/skip reason]
+</verification>
+
+Use the following when no code changes were made:
+<summary>
+No changes required
+</summary>
+<verification>
+- Tests passed: [not run - reason]
+- LSP diagnostics: [not run - reason]
+</verification>`;

+ 25 - 0
src/agents/types.ts

@@ -0,0 +1,25 @@
+import type { AgentConfig } from "@opencode-ai/sdk";
+
+export interface AgentDefinition {
+  name: string;
+  description?: string;
+  config: AgentConfig;
+}
+
+export type PermissionValue = "ask" | "allow" | "deny";
+
+export const PRIMARY_AGENT_NAMES = ["orchestrator"] as const;
+export type PrimaryAgentName = (typeof PRIMARY_AGENT_NAMES)[number];
+
+export const SUBAGENT_NAMES = ["explorer", "librarian", "oracle", "designer", "fixer"] as const;
+export type SubagentName = (typeof SUBAGENT_NAMES)[number];
+
+export type AgentName = PrimaryAgentName | SubagentName;
+
+export function isSubagent(name: string): name is SubagentName {
+  return (SUBAGENT_NAMES as readonly string[]).includes(name);
+}
+
+export function getSubagentNames(): SubagentName[] {
+  return [...SUBAGENT_NAMES];
+}

+ 0 - 3
src/cli/index.ts

@@ -11,8 +11,6 @@ function parseArgs(args: string[]): InstallArgs {
   for (const arg of args) {
     if (arg === "--no-tui") {
       result.tui = false
-    } else if (arg === "--skip-auth") {
-      result.skipAuth = true
     } else if (arg.startsWith("--antigravity=")) {
       result.antigravity = arg.split("=")[1] as BooleanArg
     } else if (arg.startsWith("--openai=")) {
@@ -39,7 +37,6 @@ Options:
   --openai=yes|no        OpenAI API access (yes/no)
   --tmux=yes|no          Enable tmux integration (yes/no)
   --no-tui               Non-interactive mode (requires all flags)
-  --skip-auth            Skip authentication reminder
   -h, --help             Show this help message
 
 Examples:

+ 0 - 1
src/cli/types.ts

@@ -5,7 +5,6 @@ export interface InstallArgs {
   antigravity?: BooleanArg
   openai?: BooleanArg
   tmux?: BooleanArg
-  skipAuth?: boolean
 }
 
 export interface InstallConfig {

+ 23 - 0
src/config/constants.ts

@@ -2,10 +2,33 @@
 export const POLL_INTERVAL_MS = 500;
 export const POLL_INTERVAL_SLOW_MS = 1000;
 export const POLL_INTERVAL_BACKGROUND_MS = 2000;
+export const POLL_INTERVAL_TMUX_MS = 2000;
 
 // Timeouts
 export const DEFAULT_TIMEOUT_MS = 120_000; // 2 minutes
 export const MAX_POLL_TIME_MS = 5 * 60 * 1000; // 5 minutes
+export const TMUX_SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
 
 // Polling stability
 export const STABLE_POLLS_THRESHOLD = 3;
+
+// Agent Names
+export const AGENT_ORCHESTRATOR = "orchestrator";
+
+// Prompts & Reminders
+export const PHASE_REMINDER_TEXT = `<reminder>⚠️ MANDATORY: Understand→DELEGATE(!)→Split-and-Parallelize(?)→Plan→Execute→Verify
+Available Specialist: @oracle @librarian @explorer @designer @fixer
+</reminder>`;
+
+export const POST_READ_NUDGE_TEXT = "\n\n---\nConsider: splitting the task to parallelize, delegate to specialist(s). (if so, reference file paths/lines—don't copy file contents)";
+
+export const READ_TOOLS = ["Read", "read"];
+
+// TMUX Defaults
+export const DEFAULT_TMUX_SERVER_URL = "http://localhost:4096";
+export const DEFAULT_SUBAGENT_TITLE = "Subagent";
+
+// Background Manager
+export const BG_TASK_CANCEL_MSG = "Cancelled by user";
+export const BG_TASK_ID_PREFIX = "bg_";
+export const BG_SESSION_TITLE_PREFIX = "Background: ";

+ 0 - 1
src/config/schema.ts

@@ -7,7 +7,6 @@ export const AgentOverrideConfigSchema = z.object({
   prompt: z.string().optional(),
   prompt_append: z.string().optional(),
   variant: z.string().optional().catch(undefined),
-  disable: z.boolean().optional(),
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all)
 });
 

+ 1 - 1
src/features/background-manager.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, test, beforeEach, mock } from "bun:test"
 import { BackgroundTaskManager, type BackgroundTask, type LaunchOptions } from "./background-manager"
-import { sleep } from "../utils/polling"
+import { sleep } from "../shared"
 
 // Mock the plugin context
 function createMockContext(overrides?: {

+ 48 - 36
src/features/background-manager.ts

@@ -1,9 +1,8 @@
 import type { PluginInput } from "@opencode-ai/plugin";
-import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from "../config";
+import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS, BG_TASK_CANCEL_MSG, AGENT_ORCHESTRATOR, BG_TASK_ID_PREFIX, BG_SESSION_TITLE_PREFIX, DEFAULT_TIMEOUT_MS } from "../config/constants";
 import type { TmuxConfig } from "../config/schema";
 import type { PluginConfig } from "../config";
-import { applyAgentVariant, resolveAgentVariant } from "../utils";
-import { sleep } from "../utils/polling";
+import { applyAgentVariant, resolveAgentVariant, sleep } from "../shared";
 import { log } from "../shared/logger";
 type PromptBody = {
   messageID?: string;
@@ -39,11 +38,12 @@ export interface LaunchOptions {
 }
 
 function generateTaskId(): string {
-  return `bg_${Math.random().toString(36).substring(2, 10)}`;
+  return `${BG_TASK_ID_PREFIX}${Math.random().toString(36).substring(2, 10)}`;
 }
 
 export class BackgroundTaskManager {
   private tasks = new Map<string, BackgroundTask>();
+  private waiters = new Map<string, Array<() => void>>();
   private client: OpencodeClient;
   private directory: string;
   private pollInterval?: ReturnType<typeof setInterval>;
@@ -61,7 +61,7 @@ export class BackgroundTaskManager {
     const session = await this.client.session.create({
       body: {
         parentID: opts.parentSessionId,
-        title: `Background: ${opts.description}`,
+        title: `${BG_SESSION_TITLE_PREFIX}${opts.description}`,
       },
       query: { directory: this.directory },
     });
@@ -122,25 +122,29 @@ export class BackgroundTaskManager {
     return task;
   }
 
-  async getResult(taskId: string, block = false, timeout = 120000): Promise<BackgroundTask | null> {
+  async getResult(taskId: string, block = false, timeout = DEFAULT_TIMEOUT_MS): Promise<BackgroundTask | null> {
     const task = this.tasks.get(taskId);
     if (!task) return null;
 
-    if (!block || task.status === "completed" || task.status === "failed") {
+    if (!block || task.status !== "running") {
       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 sleep(POLL_INTERVAL_SLOW_MS);
-    }
+    // Wait for the background polling loop to complete the task
+    return new Promise((resolve) => {
+      const timer = setTimeout(() => {
+        resolve(task);
+      }, timeout);
 
-    return task;
+      const waiter = () => {
+        clearTimeout(timer);
+        resolve(task);
+      };
+
+      const taskWaiters = this.waiters.get(taskId) ?? [];
+      taskWaiters.push(waiter);
+      this.waiters.set(taskId, taskWaiters);
+    });
   }
 
   cancel(taskId?: string): number {
@@ -148,8 +152,9 @@ export class BackgroundTaskManager {
       const task = this.tasks.get(taskId);
       if (task && task.status === "running") {
         task.status = "failed";
-        task.error = "Cancelled by user";
+        task.error = BG_TASK_CANCEL_MSG;
         task.completedAt = new Date();
+        this.notifyWaiters(taskId);
         return 1;
       }
       return 0;
@@ -159,8 +164,9 @@ export class BackgroundTaskManager {
     for (const task of this.tasks.values()) {
       if (task.status === "running") {
         task.status = "failed";
-        task.error = "Cancelled by user";
+        task.error = BG_TASK_CANCEL_MSG;
         task.completedAt = new Date();
+        this.notifyWaiters(task.id);
         count++;
       }
     }
@@ -180,17 +186,20 @@ export class BackgroundTaskManager {
       return;
     }
 
+    const statusResult = await this.client.session.status().catch(() => ({ data: {} }));
+    const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+
     for (const task of runningTasks) {
-      await this.pollTask(task);
+      await this.pollTask(task, allStatuses);
     }
   }
 
-  private async pollTask(task: BackgroundTask) {
+  private async pollTask(task: BackgroundTask, allStatuses?: Record<string, { type: string }>) {
     try {
       // Check session status first
-      const statusResult = await this.client.session.status();
-      const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
-      const sessionStatus = allStatuses[task.sessionId];
+      const sessionStatus = allStatuses 
+        ? allStatuses[task.sessionId]
+        : ((await this.client.session.status()).data ?? {})[task.sessionId];
 
       // If session is still active (not idle), don't try to read messages yet
       if (sessionStatus && sessionStatus.type !== "idle") {
@@ -206,28 +215,31 @@ export class BackgroundTaskManager {
         return; // No response yet
       }
 
-      // Extract text from all assistant messages
-      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 = assistantMessages
+        .flatMap((m) => m.parts ?? [])
+        .filter((p) => (p.type === "text" || p.type === "reasoning") && p.text)
+        .map((p) => p.text)
+        .join("\n\n");
 
-      const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
       if (responseText) {
         task.result = responseText;
         task.status = "completed";
         task.completedAt = new Date();
-        // Pane closing is handled by TmuxSessionManager via polling
+        this.notifyWaiters(task.id);
       }
     } catch (error) {
       task.status = "failed";
       task.error = error instanceof Error ? error.message : String(error);
       task.completedAt = new Date();
-      // Pane closing is handled by TmuxSessionManager via polling
+      this.notifyWaiters(task.id);
+    }
+  }
+
+  private notifyWaiters(taskId: string) {
+    const taskWaiters = this.waiters.get(taskId);
+    if (taskWaiters) {
+      taskWaiters.forEach((w) => w());
+      this.waiters.delete(taskId);
     }
   }
 }

+ 6 - 8
src/features/tmux-session-manager.ts

@@ -1,6 +1,7 @@
 import type { PluginInput } from "@opencode-ai/plugin";
-import { spawnTmuxPane, closeTmuxPane, isInsideTmux } from "../utils/tmux";
+import { spawnTmuxPane, closeTmuxPane, isInsideTmux } from "../shared";
 import type { TmuxConfig } from "../config/schema";
+import { DEFAULT_TMUX_SERVER_URL, DEFAULT_SUBAGENT_TITLE, POLL_INTERVAL_TMUX_MS, TMUX_SESSION_TIMEOUT_MS } from "../config/constants";
 import { log } from "../shared/logger";
 
 type OpencodeClient = PluginInput["client"];
@@ -13,9 +14,6 @@ interface TrackedSession {
   createdAt: number;
 }
 
-const POLL_INTERVAL_MS = 2000;
-const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
-
 /**
  * TmuxSessionManager tracks child sessions (created by OpenCode's Task tool)
  * and spawns/closes tmux panes for them.
@@ -31,7 +29,7 @@ export class TmuxSessionManager {
   constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
     this.client = ctx.client;
     this.tmuxConfig = tmuxConfig;
-    this.serverUrl = ctx.serverUrl?.toString() ?? "http://localhost:4096";
+    this.serverUrl = ctx.serverUrl?.toString() ?? DEFAULT_TMUX_SERVER_URL;
     this.enabled = tmuxConfig.enabled && isInsideTmux();
 
     log("[tmux-session-manager] initialized", {
@@ -60,7 +58,7 @@ export class TmuxSessionManager {
 
     const sessionId = info.id;
     const parentId = info.parentID;
-    const title = info.title ?? "Subagent";
+    const title = info.title ?? DEFAULT_SUBAGENT_TITLE;
 
     // Skip if we're already tracking this session
     if (this.sessions.has(sessionId)) {
@@ -105,7 +103,7 @@ export class TmuxSessionManager {
   private startPolling(): void {
     if (this.pollInterval) return;
 
-    this.pollInterval = setInterval(() => this.pollSessions(), POLL_INTERVAL_MS);
+    this.pollInterval = setInterval(() => this.pollSessions(), POLL_INTERVAL_TMUX_MS);
     log("[tmux-session-manager] polling started");
   }
 
@@ -137,7 +135,7 @@ export class TmuxSessionManager {
         const isIdle = !status || status.type === "idle";
 
         // Check for timeout
-        const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
+        const isTimedOut = now - tracked.createdAt > TMUX_SESSION_TIMEOUT_MS;
 
         if (isIdle || isTimedOut) {
           sessionsToClose.push(sessionId);

+ 0 - 87
src/hooks/auto-update-checker/cache.ts

@@ -1,87 +0,0 @@
-import * as fs from "node:fs"
-import * as path from "node:path"
-import { CACHE_DIR, PACKAGE_NAME } from "./constants"
-import { log } from "../../shared/logger"
-
-interface BunLockfile {
-  workspaces?: {
-    ""?: {
-      dependencies?: Record<string, string>
-    }
-  }
-  packages?: Record<string, unknown>
-}
-
-function stripTrailingCommas(json: string): string {
-  return json.replace(/,(\s*[}\]])/g, "$1")
-}
-
-function removeFromBunLock(packageName: string): boolean {
-  const lockPath = path.join(CACHE_DIR, "bun.lock")
-  if (!fs.existsSync(lockPath)) return false
-
-  try {
-    const content = fs.readFileSync(lockPath, "utf-8")
-    const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
-    let modified = false
-
-    if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
-      delete lock.workspaces[""].dependencies[packageName]
-      modified = true
-    }
-
-    if (lock.packages?.[packageName]) {
-      delete lock.packages[packageName]
-      modified = true
-    }
-
-    if (modified) {
-      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
-      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
-    }
-
-    return modified
-  } catch {
-    return false
-  }
-}
-
-export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
-  try {
-    const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
-    const pkgJsonPath = path.join(CACHE_DIR, "package.json")
-
-    let packageRemoved = false
-    let dependencyRemoved = false
-    let lockRemoved = false
-
-    if (fs.existsSync(pkgDir)) {
-      fs.rmSync(pkgDir, { recursive: true, force: true })
-      log(`[auto-update-checker] Package removed: ${pkgDir}`)
-      packageRemoved = true
-    }
-
-    if (fs.existsSync(pkgJsonPath)) {
-      const content = fs.readFileSync(pkgJsonPath, "utf-8")
-      const pkgJson = JSON.parse(content)
-      if (pkgJson.dependencies?.[packageName]) {
-        delete pkgJson.dependencies[packageName]
-        fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
-        log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
-        dependencyRemoved = true
-      }
-    }
-
-    lockRemoved = removeFromBunLock(packageName)
-
-    if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
-      log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
-      return false
-    }
-
-    return true
-  } catch (err) {
-    log("[auto-update-checker] Failed to invalidate package:", err)
-    return false
-  }
-}

+ 157 - 133
src/hooks/auto-update-checker/checker.ts

@@ -2,44 +2,100 @@ import * as fs from "node:fs"
 import * as path from "node:path"
 import { fileURLToPath } from "node:url"
 import * as os from "node:os"
-import type { NpmDistTags, OpencodeConfig, PackageJson } from "./types"
-import {
-  PACKAGE_NAME,
-  NPM_REGISTRY_URL,
-  NPM_FETCH_TIMEOUT,
-  INSTALLED_PACKAGE_JSON,
-  USER_OPENCODE_CONFIG,
-  USER_OPENCODE_CONFIG_JSONC,
-  USER_CONFIG_DIR,
-} from "./constants"
 import { log } from "../../shared/logger"
 import { stripJsonComments } from "../../cli/config-manager"
 
-function isPrereleaseVersion(version: string): boolean {
-  return version.includes("-")
+// --- Types ---
+
+export interface NpmDistTags {
+  latest: string
+  [key: string]: string
+}
+
+export interface OpencodeConfig {
+  plugin?: string[]
+  [key: string]: unknown
+}
+
+export interface PackageJson {
+  version: string
+  name?: string
+  [key: string]: unknown
+}
+
+export interface AutoUpdateCheckerOptions {
+  showStartupToast?: boolean
+  autoUpdate?: boolean
+}
+
+export interface PluginEntryInfo {
+  entry: string
+  isPinned: boolean
+  pinnedVersion: string | null
+  configPath: string
 }
 
-function isDistTag(version: string): boolean {
-  return !/^\d/.test(version)
+interface BunLockfile {
+  workspaces?: {
+    ""?: {
+      dependencies?: Record<string, string>
+    }
+  }
+  packages?: Record<string, unknown>
+}
+
+// --- Constants ---
+
+export const PACKAGE_NAME = "oh-my-opencode-slim"
+export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
+export const NPM_FETCH_TIMEOUT = 5000
+
+function getCacheDir(): string {
+  if (process.platform === "win32") {
+    return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
+  }
+  return path.join(os.homedir(), ".cache", "opencode")
+}
+
+export const CACHE_DIR = getCacheDir()
+export const INSTALLED_PACKAGE_JSON = path.join(CACHE_DIR, "node_modules", PACKAGE_NAME, "package.json")
+
+function getUserConfigDir(): string {
+  if (process.platform === "win32") {
+    const crossPlatformDir = path.join(os.homedir(), ".config")
+    const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
+    
+    const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
+    const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
+    
+    if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
+      return crossPlatformDir
+    }
+    return appdataDir
+  }
+  return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
 }
 
+export const USER_CONFIG_DIR = getUserConfigDir()
+export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
+export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")
+
+// --- Logic ---
+
 export function extractChannel(version: string | null): string {
   if (!version) return "latest"
+  if (!/^\d/.test(version)) return version // Dist tag
   
-  if (isDistTag(version)) return version
-  
-  if (isPrereleaseVersion(version)) {
+  if (version.includes("-")) {
     const prereleasePart = version.split("-")[1]
     if (prereleasePart) {
       const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
       if (channelMatch) return channelMatch[1]
     }
   }
-  
   return "latest"
 }
 
-
 function getConfigPaths(directory: string): string[] {
   const paths = [
     path.join(directory, ".opencode", "opencode.json"),
@@ -51,44 +107,17 @@ function getConfigPaths(directory: string): string[] {
   if (process.platform === "win32") {
     const crossPlatformDir = path.join(os.homedir(), ".config")
     const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
-    
     if (appdataDir) {
       const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
-      const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
-      const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
-      
-      if (!paths.includes(alternateConfig)) paths.push(alternateConfig)
-      if (!paths.includes(alternateConfigJsonc)) paths.push(alternateConfigJsonc)
+      const altJson = path.join(alternateDir, "opencode", "opencode.json")
+      const altJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
+      if (!paths.includes(altJson)) paths.push(altJson)
+      if (!paths.includes(altJsonc)) paths.push(altJsonc)
     }
   }
-  
   return paths
 }
 
-function getLocalDevPath(directory: string): string | null {
-  for (const configPath of getConfigPaths(directory)) {
-    try {
-      if (!fs.existsSync(configPath)) continue
-      const content = fs.readFileSync(configPath, "utf-8")
-      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
-      const plugins = config.plugin ?? []
-
-      for (const entry of plugins) {
-        if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
-          try {
-            return fileURLToPath(entry)
-          } catch {
-            return entry.replace("file://", "")
-          }
-        }
-      }
-    } catch {
-      continue
-    }
-  }
-  return null
-}
-
 function findPackageJsonUp(startPath: string): string | null {
   try {
     const stat = fs.statSync(startPath)
@@ -101,59 +130,47 @@ function findPackageJsonUp(startPath: string): string | null {
           const content = fs.readFileSync(pkgPath, "utf-8")
           const pkg = JSON.parse(content) as PackageJson
           if (pkg.name === PACKAGE_NAME) return pkgPath
-        } catch { /* empty */ }
+        } catch { /* ignore */ }
       }
       const parent = path.dirname(dir)
       if (parent === dir) break
       dir = parent
     }
-  } catch { /* empty */ }
+  } catch { /* ignore */ }
   return null
 }
 
 export function getLocalDevVersion(directory: string): string | null {
-  const localPath = getLocalDevPath(directory)
-  if (!localPath) return null
-
-  try {
-    const pkgPath = findPackageJsonUp(localPath)
-    if (!pkgPath) return null
-    const content = fs.readFileSync(pkgPath, "utf-8")
-    const pkg = JSON.parse(content) as PackageJson
-    return pkg.version ?? null
-  } catch {
-    return null
+  for (const configPath of getConfigPaths(directory)) {
+    try {
+      if (!fs.existsSync(configPath)) continue
+      const config = JSON.parse(stripJsonComments(fs.readFileSync(configPath, "utf-8"))) as OpencodeConfig
+      for (const entry of (config.plugin ?? [])) {
+        if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
+          const localPath = entry.startsWith("file://") ? fileURLToPath(entry) : entry.replace("file://", "")
+          const pkgPath = findPackageJsonUp(localPath)
+          if (pkgPath) return (JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson).version ?? null
+        }
+      }
+    } catch { continue }
   }
-}
-
-export interface PluginEntryInfo {
-  entry: string
-  isPinned: boolean
-  pinnedVersion: string | null
-  configPath: string
+  return null
 }
 
 export function findPluginEntry(directory: string): PluginEntryInfo | null {
   for (const configPath of getConfigPaths(directory)) {
     try {
       if (!fs.existsSync(configPath)) continue
-      const content = fs.readFileSync(configPath, "utf-8")
-      const config = JSON.parse(stripJsonComments(content)) as OpencodeConfig
-      const plugins = config.plugin ?? []
-
-      for (const entry of plugins) {
-        if (entry === PACKAGE_NAME) {
-          return { entry, isPinned: false, pinnedVersion: null, configPath }
-        }
+      const config = JSON.parse(stripJsonComments(fs.readFileSync(configPath, "utf-8"))) as OpencodeConfig
+      for (const entry of (config.plugin ?? [])) {
+        if (entry === PACKAGE_NAME) return { entry, isPinned: false, pinnedVersion: null, configPath }
         if (entry.startsWith(`${PACKAGE_NAME}@`)) {
           const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1)
           const isPinned = pinnedVersion !== "latest"
           return { entry, isPinned, pinnedVersion: isPinned ? pinnedVersion : null, configPath }
         }
       }
-    } catch {
-      continue
-    }
+    } catch { continue }
   }
   return null
 }
@@ -161,24 +178,12 @@ export function findPluginEntry(directory: string): PluginEntryInfo | null {
 export function getCachedVersion(): string | null {
   try {
     if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
-      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
-      const pkg = JSON.parse(content) as PackageJson
-      if (pkg.version) return pkg.version
+      return (JSON.parse(fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")) as PackageJson).version
     }
-  } catch { /* empty */ }
-
-  try {
     const currentDir = path.dirname(fileURLToPath(import.meta.url))
     const pkgPath = findPackageJsonUp(currentDir)
-    if (pkgPath) {
-      const content = fs.readFileSync(pkgPath, "utf-8")
-      const pkg = JSON.parse(content) as PackageJson
-      if (pkg.version) return pkg.version
-    }
-  } catch (err) {
-    log("[auto-update] check: Failed to resolve version from current directory:", err instanceof Error ? err.message : String(err))
-  }
-
+    if (pkgPath) return (JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson).version
+  } catch { /* ignore */ }
   return null
 }
 
@@ -186,70 +191,89 @@ export function updatePinnedVersion(configPath: string, oldEntry: string, newVer
   try {
     const content = fs.readFileSync(configPath, "utf-8")
     const newEntry = `${PACKAGE_NAME}@${newVersion}`
-    
     const pluginMatch = content.match(/"plugin"\s*:\s*\[/)
-    if (!pluginMatch || pluginMatch.index === undefined) {
-      log(`[auto-update] update: No "plugin" array found in ${configPath}`)
-      return false
-    }
+    if (!pluginMatch || pluginMatch.index === undefined) return false
     
     const startIdx = pluginMatch.index + pluginMatch[0].length
-    let bracketCount = 1
-    let endIdx = startIdx
-    
+    let bracketCount = 1, endIdx = startIdx
     for (let i = startIdx; i < content.length && bracketCount > 0; i++) {
       if (content[i] === "[") bracketCount++
       else if (content[i] === "]") bracketCount--
       endIdx = i
     }
     
-    const before = content.slice(0, startIdx)
     const pluginArrayContent = content.slice(startIdx, endIdx)
-    const after = content.slice(endIdx)
-    
     const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
     const regex = new RegExp(`["']${escapedOldEntry}["']`)
     
-    if (!regex.test(pluginArrayContent)) {
-      log(`[auto-update] update: Entry "${oldEntry}" not found in plugin array of ${configPath}`)
-      return false
-    }
+    if (!regex.test(pluginArrayContent)) return false
+    const updatedContent = content.slice(0, startIdx) + pluginArrayContent.replace(regex, `"${newEntry}"`) + content.slice(endIdx)
     
-    const updatedPluginArray = pluginArrayContent.replace(regex, `"${newEntry}"`)
-    const updatedContent = before + updatedPluginArray + after
-    
-    if (updatedContent === content) {
-      log(`[auto-update] update: No changes made to ${configPath}`)
-      return false
+    if (updatedContent !== content) {
+      fs.writeFileSync(configPath, updatedContent, "utf-8")
+      log(`[auto-update] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
+      return true
     }
-    
-    fs.writeFileSync(configPath, updatedContent, "utf-8")
-    log(`[auto-update] update: Updated ${configPath}: ${oldEntry} → ${newEntry}`)
-    return true
   } catch (err) {
-    log(`[auto-update] update: Failed to update config file ${configPath}:`, err instanceof Error ? err.message : String(err))
-    return false
+    log(`[auto-update] Update failed: ${err instanceof Error ? err.message : String(err)}`)
   }
+  return false
 }
 
 export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
   const controller = new AbortController()
   const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
-
   try {
-    const response = await fetch(NPM_REGISTRY_URL, {
-      signal: controller.signal,
-      headers: { Accept: "application/json" },
-    })
-
+    const response = await fetch(NPM_REGISTRY_URL, { signal: controller.signal, headers: { Accept: "application/json" } })
     if (!response.ok) return null
-
     const data = (await response.json()) as NpmDistTags
     return data[channel] ?? data.latest ?? null
   } catch (err) {
-    log(`[auto-update] fetch: failed to fetch latest version: ${err instanceof Error ? err.message : String(err)}`)
+    log(`[auto-update] fetch failed: ${err instanceof Error ? err.message : String(err)}`)
     return null
-  } finally {
-    clearTimeout(timeoutId)
+  } finally { clearTimeout(timeoutId) }
+}
+
+export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
+  try {
+    const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
+    const pkgJsonPath = path.join(CACHE_DIR, "package.json")
+    let modified = false
+
+    if (fs.existsSync(pkgDir)) {
+      fs.rmSync(pkgDir, { recursive: true, force: true })
+      modified = true
+    }
+
+    if (fs.existsSync(pkgJsonPath)) {
+      const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"))
+      if (pkgJson.dependencies?.[packageName]) {
+        delete pkgJson.dependencies[packageName]
+        fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
+        modified = true
+      }
+    }
+
+    const lockPath = path.join(CACHE_DIR, "bun.lock")
+    if (fs.existsSync(lockPath)) {
+      const lock = JSON.parse(fs.readFileSync(lockPath, "utf-8").replace(/,(\s*[}\]])/g, "$1")) as BunLockfile
+      let lockModified = false
+      if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
+        delete lock.workspaces[""].dependencies[packageName]
+        lockModified = true
+      }
+      if (lock.packages?.[packageName]) {
+        delete lock.packages[packageName]
+        lockModified = true
+      }
+      if (lockModified) {
+        fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
+        modified = true
+      }
+    }
+    return modified
+  } catch (err) {
+    log("[auto-update] Invalidation failed:", err)
+    return false
   }
 }

+ 0 - 43
src/hooks/auto-update-checker/constants.ts

@@ -1,43 +0,0 @@
-import * as path from "node:path"
-import * as os from "node:os"
-import * as fs from "node:fs"
-
-export const PACKAGE_NAME = "oh-my-opencode-slim"
-export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
-export const NPM_FETCH_TIMEOUT = 5000
-
-function getCacheDir(): string {
-  if (process.platform === "win32") {
-    return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
-  }
-  return path.join(os.homedir(), ".cache", "opencode")
-}
-
-export const CACHE_DIR = getCacheDir()
-export const INSTALLED_PACKAGE_JSON = path.join(
-  CACHE_DIR,
-  "node_modules",
-  PACKAGE_NAME,
-  "package.json"
-)
-
-function getUserConfigDir(): string {
-  if (process.platform === "win32") {
-    const crossPlatformDir = path.join(os.homedir(), ".config")
-    const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
-    
-    const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
-    const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
-    
-    if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
-      return crossPlatformDir
-    }
-    
-    return appdataDir
-  }
-  return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
-}
-
-export const USER_CONFIG_DIR = getUserConfigDir()
-export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
-export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")

+ 30 - 59
src/hooks/auto-update-checker/index.ts

@@ -1,20 +1,25 @@
 import type { PluginInput } from "@opencode-ai/plugin"
-import { getCachedVersion, getLocalDevVersion, findPluginEntry, getLatestVersion, updatePinnedVersion, extractChannel } from "./checker"
-import { invalidatePackage } from "./cache"
-import { PACKAGE_NAME } from "./constants"
+import { 
+  getCachedVersion, 
+  getLocalDevVersion, 
+  findPluginEntry, 
+  getLatestVersion, 
+  updatePinnedVersion, 
+  extractChannel,
+  invalidatePackage,
+  PACKAGE_NAME,
+  type AutoUpdateCheckerOptions
+} from "./checker"
 import { log } from "../../shared/logger"
-import type { AutoUpdateCheckerOptions } from "./types"
 
 export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
   const { showStartupToast = true, autoUpdate = true } = options
-
   let hasChecked = false
 
   return {
     event: ({ event }: { event: { type: string; properties?: unknown } }) => {
-      if (event.type !== "session.created") return
-      if (hasChecked) return
-
+      if (event.type !== "session.created" || hasChecked) return
+      
       const props = event.properties as { info?: { parentID?: string } } | undefined
       if (props?.info?.parentID) return
 
@@ -29,7 +34,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
           if (showStartupToast) {
             showToast(ctx, `OMO-Slim ${displayVersion} (dev)`, "Running in local development mode.", "info")
           }
-          log("[auto-update-checker] Local development mode")
+          log("[auto-update] Local development mode")
           return
         }
 
@@ -38,7 +43,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
         }
 
         runBackgroundUpdateCheck(ctx, autoUpdate).catch(err => {
-          log("[auto-update-checker] Background update check failed:", err)
+          log("[auto-update] Background update check failed:", err)
         })
       }, 0)
     },
@@ -47,83 +52,49 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
 
 async function runBackgroundUpdateCheck(ctx: PluginInput, autoUpdate: boolean): Promise<void> {
   const pluginInfo = findPluginEntry(ctx.directory)
-  if (!pluginInfo) {
-    log("[auto-update-checker] Plugin not found in config")
-    return
-  }
+  if (!pluginInfo) return
 
-  const cachedVersion = getCachedVersion()
-  const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion
-  if (!currentVersion) {
-    log("[auto-update-checker] No version found (cached or pinned)")
-    return
-  }
+  const currentVersion = getCachedVersion() ?? pluginInfo.pinnedVersion
+  if (!currentVersion) return
 
   const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
   const latestVersion = await getLatestVersion(channel)
-  if (!latestVersion) {
-    log("[auto-update-checker] Failed to fetch latest version for channel:", channel)
-    return
-  }
-
-  if (currentVersion === latestVersion) {
-    log("[auto-update-checker] Already on latest version for channel:", channel)
-    return
-  }
+  
+  if (!latestVersion || currentVersion === latestVersion) return
 
-  log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`)
+  log(`[auto-update] Update available (${channel}): ${currentVersion} → ${latestVersion}`)
 
   if (!autoUpdate) {
     showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
-    log("[auto-update-checker] Auto-update disabled, notification only")
     return
   }
 
   if (pluginInfo.isPinned) {
-    const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
-    if (!updated) {
+    if (!updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)) {
       showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
-      log("[auto-update-checker] Failed to update pinned version in config")
       return
     }
-    log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
   }
 
   invalidatePackage(PACKAGE_NAME)
 
-  const installSuccess = await runBunInstallSafe(ctx)
-
-  if (installSuccess) {
+  if (await runBunInstallSafe(ctx)) {
     showToast(ctx, "OMO-Slim Updated!", `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`, "success", 8000)
-    log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
+    log(`[auto-update] Update installed: ${currentVersion} → ${latestVersion}`)
   } else {
     showToast(ctx, `OMO-Slim ${latestVersion}`, `v${latestVersion} available. Restart to apply.`, "info", 8000)
-    log("[auto-update-checker] bun install failed; update not installed")
   }
 }
 
 async function runBunInstallSafe(ctx: PluginInput): Promise<boolean> {
   try {
-    const proc = Bun.spawn(["bun", "install"], {
-      cwd: ctx.directory,
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    
-    const timeoutPromise = new Promise<"timeout">((resolve) =>
-      setTimeout(() => resolve("timeout"), 60_000)
-    )
-    const exitPromise = proc.exited.then(() => "completed" as const)
-    const result = await Promise.race([exitPromise, timeoutPromise])
-    
-    if (result === "timeout") {
-      try { proc.kill() } catch { /* empty */ }
-      return false
-    }
-    
+    const proc = Bun.spawn(["bun", "install"], { cwd: ctx.directory })
+    const timeout = setTimeout(() => proc.kill(), 60_000)
+    await proc.exited
+    clearTimeout(timeout)
     return proc.exitCode === 0
   } catch (err) {
-    log("[auto-update-checker] bun install error:", err)
+    log("[auto-update] bun install error:", err)
     return false
   }
 }
@@ -140,4 +111,4 @@ function showToast(
   }).catch(() => {})
 }
 
-export type { AutoUpdateCheckerOptions } from "./types"
+export type { AutoUpdateCheckerOptions }

+ 0 - 20
src/hooks/auto-update-checker/types.ts

@@ -1,20 +0,0 @@
-export interface NpmDistTags {
-  latest: string
-  [key: string]: string
-}
-
-export interface OpencodeConfig {
-  plugin?: string[]
-  [key: string]: unknown
-}
-
-export interface PackageJson {
-  version: string
-  name?: string
-  [key: string]: unknown
-}
-
-export interface AutoUpdateCheckerOptions {
-  showStartupToast?: boolean
-  autoUpdate?: boolean
-}

+ 8 - 30
src/hooks/phase-reminder/index.ts

@@ -1,3 +1,5 @@
+import { AGENT_ORCHESTRATOR, PHASE_REMINDER_TEXT } from "../../config/constants";
+
 /**
  * Phase reminder to inject before each user message.
  * Keeps workflow instructions in the immediate attention window
@@ -8,9 +10,6 @@
  * 
  * Uses experimental.chat.messages.transform so it doesn't show in UI.
  */
-const PHASE_REMINDER = `<reminder>⚠️ MANDATORY: Understand→DELEGATE(!)→Split-and-Parallelize(?)→Plan→Execute→Verify
-Available Specialist: @oracle @librarian @explorer @designer @fixer
-</reminder>`;
 
 interface MessageInfo {
   role: string;
@@ -42,43 +41,22 @@ export function createPhaseReminderHook() {
     ): Promise<void> => {
       const { messages } = output;
       
-      if (messages.length === 0) {
-        return;
-      }
+      const lastUserMessage = [...messages].reverse().find(m => m.info.role === "user");
+      if (!lastUserMessage) return;
 
-      // Find the last user message
-      let lastUserMessageIndex = -1;
-      for (let i = messages.length - 1; i >= 0; i--) {
-        if (messages[i].info.role === "user") {
-          lastUserMessageIndex = i;
-          break;
-        }
-      }
-
-      if (lastUserMessageIndex === -1) {
-        return;
-      }
-
-      const lastUserMessage = messages[lastUserMessageIndex];
-      
       // Only inject for orchestrator (or if no agent specified = main session)
       const agent = lastUserMessage.info.agent;
-      if (agent && agent !== "orchestrator") {
+      if (agent && agent !== AGENT_ORCHESTRATOR) {
         return;
       }
 
-      // Find the first text part
-      const textPartIndex = lastUserMessage.parts.findIndex(
+      const textPart = lastUserMessage.parts.find(
         (p) => p.type === "text" && p.text !== undefined
       );
 
-      if (textPartIndex === -1) {
-        return;
+      if (textPart) {
+        textPart.text = `${PHASE_REMINDER_TEXT}\n\n---\n\n${textPart.text}`;
       }
-
-      // Prepend the reminder to the existing text
-      const originalText = lastUserMessage.parts[textPartIndex].text ?? "";
-      lastUserMessage.parts[textPartIndex].text = `${PHASE_REMINDER}\n\n---\n\n${originalText}`;
     },
   };
 }

+ 4 - 4
src/hooks/post-read-nudge/index.ts

@@ -1,10 +1,10 @@
+import { POST_READ_NUDGE_TEXT, READ_TOOLS } from "../../config/constants";
+
 /**
  * Post-Read nudge - appends a delegation reminder after file reads.
  * Catches the "read files → implement myself" anti-pattern.
  */
 
-const NUDGE = "\n\n---\nConsider: splitting the task to parallelize, delegate to specialist(s). (if so, reference file paths/lines—don't copy file contents)";
-
 interface ToolExecuteAfterInput {
   tool: string;
   sessionID?: string;
@@ -24,12 +24,12 @@ export function createPostReadNudgeHook() {
       output: ToolExecuteAfterOutput
     ): Promise<void> => {
       // Only nudge for Read tool
-      if (input.tool !== "Read" && input.tool !== "read") {
+      if (!READ_TOOLS.includes(input.tool)) {
         return;
       }
 
       // Append the nudge
-      output.output = output.output + NUDGE;
+      output.output = output.output + POST_READ_NUDGE_TEXT;
     },
   };
 }

+ 1 - 1
src/index.ts

@@ -17,7 +17,7 @@ import {
 import { loadPluginConfig, type TmuxConfig } from "./config";
 import { createBuiltinMcps } from "./mcp";
 import { createAutoUpdateCheckerHook, createPhaseReminderHook, createPostReadNudgeHook } from "./hooks";
-import { startTmuxCheck } from "./utils";
+import { startTmuxCheck } from "./shared";
 import { log } from "./shared/logger";
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {

src/utils/agent-variant.test.ts → src/shared/agent-variant.test.ts


+ 1 - 1
src/utils/agent-variant.ts

@@ -1,5 +1,5 @@
 import type { PluginConfig } from "../config";
-import { log } from "../shared/logger";
+import { log } from "./logger";
 
 /**
  * Normalizes an agent name by trimming whitespace and removing leading '@'.

+ 3 - 40
src/tools/shared/downloader-utils.ts

@@ -2,8 +2,8 @@ import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync, renameSync }
 import { join } from "node:path"
 import { homedir } from "node:os"
 import { spawn } from "bun"
-import { extractZip } from "../../shared"
-import { log } from "../../shared/logger"
+import { extractZip } from "./zip-extractor"
+import { log } from "./logger"
 
 export interface DownloadOptions {
   binaryName: string
@@ -15,14 +15,6 @@ export interface DownloadOptions {
   tarInclude?: string
 }
 
-/**
- * Downloads a file from a URL to a local destination path.
- * Supports redirects.
- * @param url - The URL to download from.
- * @param destPath - The local path to save the file to.
- * @returns A promise that resolves when the download is complete.
- * @throws Error if the download fails.
- */
 export async function downloadFile(url: string, destPath: string): Promise<void> {
   const response = await fetch(url, { redirect: "follow" })
   if (!response.ok) {
@@ -32,14 +24,6 @@ export async function downloadFile(url: string, destPath: string): Promise<void>
   await Bun.write(destPath, buffer)
 }
 
-/**
- * Extracts a .tar.gz archive using the system 'tar' command.
- * @param archivePath - Path to the archive file.
- * @param destDir - Directory to extract into.
- * @param include - Optional pattern of files to include.
- * @returns A promise that resolves when extraction is complete.
- * @throws Error if extraction fails.
- */
 export async function extractTarGz(archivePath: string, destDir: string, include?: string): Promise<void> {
   const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
   
@@ -64,18 +48,11 @@ export async function extractTarGz(archivePath: string, destDir: string, include
   }
 }
 
-/**
- * Recursively searches for a file with a specific name within a directory.
- * @param dir - The directory to search in.
- * @param filename - The name of the file to find.
- * @returns The full path to the found file, or null if not found.
- */
 export function findFileRecursive(dir: string, filename: string): string | null {
   try {
     const entries = readdirSync(dir, { withFileTypes: true, recursive: true })
     for (const entry of entries) {
       if (entry.isFile() && entry.name === filename) {
-        // Bun 1.1+ supports entry.parentPath, fallback to dir if not available
         return join((entry as any).parentPath ?? dir, entry.name)
       }
     }
@@ -85,10 +62,6 @@ export function findFileRecursive(dir: string, filename: string): string | null
   return null
 }
 
-/**
- * Gets the default installation directory for binaries based on the OS.
- * @returns The absolute path to the default installation directory.
- */
 export function getDefaultInstallDir(): string {
   const home = homedir()
   if (process.platform === "win32") {
@@ -102,12 +75,6 @@ export function getDefaultInstallDir(): string {
   return join(base, "oh-my-opencode-slim", "bin")
 }
 
-/**
- * Ensures a binary is available locally, downloading and extracting it if necessary.
- * @param options - Download and installation options.
- * @returns A promise resolving to the absolute path of the binary.
- * @throws Error if download or extraction fails, or if the binary is not found.
- */
 export async function ensureBinary(options: DownloadOptions): Promise<string> {
   const { binaryName, url, installDir, archiveName, isZip, tarInclude } = options
   const isWindows = process.platform === "win32"
@@ -130,8 +97,6 @@ export async function ensureBinary(options: DownloadOptions): Promise<string> {
 
     if (isZip) {
       await extractZip(archivePath, installDir)
-      
-      // Some zips have nested structures, try to find the binary and move it to the root of installDir
       const foundPath = findFileRecursive(installDir, fullBinaryName)
       if (foundPath && foundPath !== binaryPath) {
         renameSync(foundPath, binaryPath)
@@ -154,9 +119,7 @@ export async function ensureBinary(options: DownloadOptions): Promise<string> {
     if (existsSync(archivePath)) {
       try {
         unlinkSync(archivePath)
-      } catch {
-        // Cleanup failures are non-critical
-      }
+      } catch { /* ignore */ }
     }
   }
 }

+ 22 - 0
src/shared/formatters.ts

@@ -0,0 +1,22 @@
+export function groupByFile<T extends { file: string }>(items: T[]): Map<string, T[]> {
+  const byFile = new Map<string, T[]>()
+  for (const item of items) {
+    const existing = byFile.get(item.file) || []
+    existing.push(item)
+    byFile.set(item.file, existing)
+  }
+  return byFile
+}
+
+export function formatSimpleResults<T extends { line: number; text: string }>(
+  file: string,
+  matches: T[],
+  limit = 100
+): string[] {
+  const lines: string[] = [`\n${file}:`]
+  for (const match of matches) {
+    const text = match.text.length > limit ? match.text.substring(0, limit) + "..." : match.text
+    lines.push(`  ${match.line}: ${text.replace(/\n/g, "\\n")}`)
+  }
+  return lines
+}

+ 7 - 2
src/shared/index.ts

@@ -1,2 +1,7 @@
-export { extractZip } from "./zip-extractor"
-export { log } from "./logger"
+export * from "./logger";
+export * from "./zip-extractor";
+export * from "./polling";
+export * from "./tmux";
+export * from "./agent-variant";
+export * from "./binary-downloader";
+export * from "./formatters";

src/utils/polling.ts → src/shared/polling.ts


+ 1 - 1
src/utils/tmux.ts

@@ -1,5 +1,5 @@
 import { spawn } from "bun";
-import { log } from "../shared/logger";
+import { log } from "./logger";
 import type { TmuxConfig, TmuxLayout } from "../config/schema";
 import { sleep } from "./polling";
 

+ 5 - 2
src/tools/ast-grep/downloader.ts

@@ -1,8 +1,11 @@
 import { existsSync } from "node:fs"
 import { join } from "node:path"
 import { createRequire } from "node:module"
-import { getDefaultInstallDir, ensureBinary } from "../shared/downloader-utils"
-export { getDefaultInstallDir as getCacheDir } from "../shared/downloader-utils"
+import { getDefaultInstallDir, ensureBinary } from "../../shared/binary-downloader"
+
+export function getCacheDir(): string {
+  return getDefaultInstallDir()
+}
 
 const REPO = "ast-grep/ast-grep"
 

+ 2 - 6
src/tools/ast-grep/utils.ts

@@ -1,3 +1,4 @@
+import { groupByFile } from "../../shared"
 import type { SgResult, CliLanguage } from "./types"
 
 /**
@@ -17,12 +18,7 @@ export function formatSearchResult(result: SgResult): string {
   const lines: string[] = []
 
   // Group matches by file
-  const byFile = new Map<string, typeof result.matches>()
-  for (const match of result.matches) {
-    const existing = byFile.get(match.file) || []
-    existing.push(match)
-    byFile.set(match.file, existing)
-  }
+  const byFile = groupByFile(result.matches)
 
   for (const [file, matches] of byFile) {
     lines.push(`\n${file}:`)

+ 1 - 2
src/tools/background.ts

@@ -9,8 +9,7 @@ import {
 } from "../config";
 import type { TmuxConfig } from "../config/schema";
 import type { PluginConfig } from "../config";
-import { applyAgentVariant, resolveAgentVariant } from "../utils";
-import { sleep } from "../utils/polling";
+import { applyAgentVariant, resolveAgentVariant, sleep } from "../shared";
 import { log } from "../shared/logger";
 
 const z = tool.schema;

+ 5 - 46
src/tools/grep/cli.ts

@@ -9,11 +9,10 @@ import {
   DEFAULT_TIMEOUT_MS,
   DEFAULT_MAX_OUTPUT_BYTES,
   RG_SAFETY_FLAGS,
-  GREP_SAFETY_FLAGS,
 } from "./constants"
 import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
 
-function buildRgArgs(options: GrepOptions): string[] {
+function buildArgs(options: GrepOptions): string[] {
   const args: string[] = [
     ...RG_SAFETY_FLAGS,
     `--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
@@ -54,38 +53,6 @@ function buildRgArgs(options: GrepOptions): string[] {
   return args
 }
 
-function buildGrepArgs(options: GrepOptions): string[] {
-  const args: string[] = [...GREP_SAFETY_FLAGS, "-r"]
-
-  if (options.context !== undefined && options.context > 0) {
-    args.push(`-C${Math.min(options.context, 10)}`)
-  }
-
-  if (!options.caseSensitive) args.push("-i")
-  if (options.wholeWord) args.push("-w")
-  if (options.fixedStrings) args.push("-F")
-
-  if (options.globs?.length) {
-    for (const glob of options.globs) {
-      args.push(`--include=${glob}`)
-    }
-  }
-
-  if (options.excludeGlobs?.length) {
-    for (const glob of options.excludeGlobs) {
-      args.push(`--exclude=${glob}`)
-    }
-  }
-
-  args.push("--exclude-dir=.git", "--exclude-dir=node_modules")
-
-  return args
-}
-
-function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
-  return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
-}
-
 function parseOutput(output: string): GrepMatch[] {
   if (!output.trim()) return []
 
@@ -137,14 +104,10 @@ function parseCountOutput(output: string): CountResult[] {
  */
 export async function runRg(options: GrepOptions): Promise<GrepResult> {
   const cli = resolveGrepCli()
-  const args = buildArgs(options, cli.backend)
+  const args = buildArgs(options)
   const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
 
-  if (cli.backend === "rg") {
-    args.push("--", options.pattern)
-  } else {
-    args.push("-e", options.pattern)
-  }
+  args.push("--", options.pattern)
 
   const paths = options.paths?.length ? options.paths : ["."]
   args.push(...paths)
@@ -207,13 +170,9 @@ export async function runRg(options: GrepOptions): Promise<GrepResult> {
  */
 export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
   const cli = resolveGrepCli()
-  const args = buildArgs({ ...options, context: 0 }, cli.backend)
+  const args = buildArgs({ ...options, context: 0 })
 
-  if (cli.backend === "rg") {
-    args.push("--count", "--", options.pattern)
-  } else {
-    args.push("-c", "-e", options.pattern)
-  }
+  args.push("--count", "--", options.pattern)
 
   const paths = options.paths?.length ? options.paths : ["."]
   args.push(...paths)

+ 6 - 11
src/tools/grep/constants.ts

@@ -3,11 +3,11 @@ import { join, dirname } from "node:path"
 import { spawnSync } from "node:child_process"
 import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
 
-export type GrepBackend = "rg" | "grep"
+export type GrepBackend = "rg"
 
 interface ResolvedCli {
   path: string
-  backend: GrepBackend
+  backend: "rg"
 }
 
 let cachedCli: ResolvedCli | null = null
@@ -82,12 +82,7 @@ export function resolveGrepCli(): ResolvedCli {
     return cachedCli
   }
 
-  const grep = findExecutable("grep")
-  if (grep) {
-    cachedCli = { path: grep, backend: "grep" }
-    return cachedCli
-  }
-
+  // Fallback to "rg" in PATH even if not found by findExecutable (spawn will fail but it's consistent)
   cachedCli = { path: "rg", backend: "rg" }
   return cachedCli
 }
@@ -95,7 +90,9 @@ export function resolveGrepCli(): ResolvedCli {
 export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
   const current = resolveGrepCli()
 
-  if (current.backend === "rg") {
+  // In this version, we always want rg. If not found, try to install.
+  // We check if the current path actually exists.
+  if (existsSync(current.path)) {
     return current
   }
 
@@ -129,5 +126,3 @@ export const RG_SAFETY_FLAGS = [
   "--line-number",
   "--with-filename",
 ] as const
-
-export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const

+ 1 - 1
src/tools/grep/downloader.ts

@@ -1,6 +1,6 @@
 import { existsSync } from "node:fs"
 import { join } from "node:path"
-import { getDefaultInstallDir, ensureBinary } from "../shared/downloader-utils"
+import { getDefaultInstallDir, ensureBinary } from "../../shared/binary-downloader"
 
 const RG_VERSION = "14.1.1"
 

+ 2 - 6
src/tools/grep/utils.ts

@@ -1,3 +1,4 @@
+import { groupByFile } from "../../shared"
 import type { GrepResult } from "./types"
 
 export function formatGrepResult(result: GrepResult): string {
@@ -12,12 +13,7 @@ export function formatGrepResult(result: GrepResult): string {
   const lines: string[] = []
 
   // Group matches by file
-  const byFile = new Map<string, { line: number; text: string }[]>()
-  for (const match of result.matches) {
-    const existing = byFile.get(match.file) || []
-    existing.push({ line: match.line, text: match.text })
-    byFile.set(match.file, existing)
-  }
+  const byFile = groupByFile(result.matches)
 
   for (const [file, matches] of byFile) {
     lines.push(`\n${file}:`)

+ 1 - 1
src/tools/lsp/client.ts

@@ -7,7 +7,7 @@ import { pathToFileURL } from "node:url"
 import { getLanguageId } from "./config"
 import type { Diagnostic, ResolvedServer } from "./types"
 import { parseMessages } from "./protocol-parser"
-import { sleep } from "../../utils/polling"
+import { sleep } from "../../shared"
 
 interface ManagedClient {
   client: LSPClient

+ 0 - 185
src/tools/quota/api.ts

@@ -1,185 +0,0 @@
-import * as path from "path";
-import * as os from "os";
-import * as fs from "fs";
-import { sleep } from "../../utils/polling";
-import type {
-  Account,
-  AccountsConfig,
-  TokenResponse,
-  LoadCodeAssistResponse,
-  QuotaResponse,
-  AccountQuotaResult,
-  ModelQuota,
-} from "./types";
-
-// API endpoints
-const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
-const CLOUDCODE_BASE_URL = "https://cloudcode-pa.googleapis.com";
-
-// Timing constants
-const DEFAULT_RESET_MS = 86_400_000; // 24 hours - fallback when API doesn't provide reset time
-const ACCOUNT_FETCH_DELAY_MS = 200; // Delay between account fetches to avoid rate limiting
-const CLOUDCODE_METADATA = {
-  ideType: "ANTIGRAVITY",
-  platform: "PLATFORM_UNSPECIFIED",
-  pluginType: "GEMINI",
-};
-
-// Client credentials (from opencode-antigravity-auth)
-const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
-const CLIENT_SECRET = "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";
-
-// Config paths
-const isWindows = os.platform() === "win32";
-const configBase = isWindows
-  ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
-  : path.join(os.homedir(), ".config", "opencode");
-
-const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
-const dataBase = isWindows ? configBase : path.join(xdgData, "opencode");
-
-export const CONFIG_PATHS = [
-  path.join(configBase, "antigravity-accounts.json"),
-  path.join(dataBase, "antigravity-accounts.json"),
-];
-
-export function loadAccountsConfig(): AccountsConfig | null {
-  for (const p of CONFIG_PATHS) {
-    if (fs.existsSync(p)) {
-      return JSON.parse(fs.readFileSync(p, "utf-8")) as AccountsConfig;
-    }
-  }
-  return null;
-}
-
-async function refreshToken(refreshToken: string): Promise<string> {
-  const params = new URLSearchParams({
-    client_id: CLIENT_ID,
-    client_secret: CLIENT_SECRET,
-    refresh_token: refreshToken,
-    grant_type: "refresh_token",
-  });
-
-  const res = await fetch(GOOGLE_TOKEN_URL, {
-    method: "POST",
-    headers: { "Content-Type": "application/x-www-form-urlencoded" },
-    body: params.toString(),
-  });
-
-  if (!res.ok) throw new Error(`Token refresh failed (${res.status})`);
-  const data = (await res.json()) as TokenResponse;
-  return data.access_token;
-}
-
-async function loadCodeAssist(accessToken: string): Promise<LoadCodeAssistResponse> {
-  const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:loadCodeAssist`, {
-    method: "POST",
-    headers: {
-      Authorization: `Bearer ${accessToken}`,
-      "Content-Type": "application/json",
-      "User-Agent": "antigravity",
-    },
-    body: JSON.stringify({ metadata: CLOUDCODE_METADATA }),
-  });
-
-  if (!res.ok) throw new Error(`loadCodeAssist failed (${res.status})`);
-  return (await res.json()) as LoadCodeAssistResponse;
-}
-
-function extractProjectId(project: unknown): string | undefined {
-  if (typeof project === "string" && project) return project;
-  if (project && typeof project === "object" && "id" in project) {
-    const id = (project as { id?: string }).id;
-    if (id) return id;
-  }
-  return undefined;
-}
-
-async function fetchModels(accessToken: string, projectId?: string): Promise<QuotaResponse> {
-  const payload = projectId ? { project: projectId } : {};
-  const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:fetchAvailableModels`, {
-    method: "POST",
-    headers: {
-      Authorization: `Bearer ${accessToken}`,
-      "Content-Type": "application/json",
-      "User-Agent": "antigravity",
-    },
-    body: JSON.stringify(payload),
-  });
-
-  if (!res.ok) throw new Error(`fetchModels failed (${res.status})`);
-  return (await res.json()) as QuotaResponse;
-}
-
-function formatDuration(ms: number): string {
-  const seconds = Math.floor(Math.abs(ms) / 1000);
-  const h = Math.floor(seconds / 3600);
-  const m = Math.floor((seconds % 3600) / 60);
-  if (h > 0) return `${h}h${m}m`;
-  return `${m}m`;
-}
-
-// Filter out internal/test models
-const EXCLUDED_PATTERNS = ["chat_", "rev19", "gemini 2.5", "gemini 3 pro image"];
-
-export async function fetchAccountQuota(account: Account): Promise<AccountQuotaResult> {
-  try {
-    const accessToken = await refreshToken(account.refreshToken);
-    let projectId = account.projectId || account.managedProjectId;
-
-    if (!projectId) {
-      const codeAssist = await loadCodeAssist(accessToken);
-      projectId = extractProjectId(codeAssist.cloudaicompanionProject);
-    }
-
-    const quotaRes = await fetchModels(accessToken, projectId);
-    if (!quotaRes.models) {
-      return { email: account.email, success: true, models: [] };
-    }
-
-    const now = Date.now();
-    const models: ModelQuota[] = [];
-
-    for (const [key, info] of Object.entries(quotaRes.models)) {
-      const qi = info.quotaInfo;
-      if (!qi) continue;
-
-      const label = info.displayName || key;
-      const lower = label.toLowerCase();
-      if (EXCLUDED_PATTERNS.some((p) => lower.includes(p))) continue;
-
-      const pct = Math.min(100, Math.max(0, (qi.remainingFraction ?? 0) * 100));
-      let resetMs = DEFAULT_RESET_MS;
-      if (qi.resetTime) {
-        const parsed = new Date(qi.resetTime).getTime();
-        if (!isNaN(parsed)) resetMs = Math.max(0, parsed - now);
-      }
-
-      models.push({
-        name: label,
-        percent: pct,
-        resetIn: formatDuration(resetMs),
-      });
-    }
-
-    // Sort by name
-    models.sort((a, b) => a.name.localeCompare(b.name));
-    return { email: account.email, success: true, models };
-  } catch (err) {
-    return {
-      email: account.email,
-      success: false,
-      error: err instanceof Error ? err.message : String(err),
-      models: [],
-    };
-  }
-}
-
-export async function fetchAllQuotas(accounts: Account[]): Promise<AccountQuotaResult[]> {
-  const results: AccountQuotaResult[] = [];
-  for (let i = 0; i < accounts.length; i++) {
-    if (i > 0) await sleep(ACCOUNT_FETCH_DELAY_MS);
-    results.push(await fetchAccountQuota(accounts[i]));
-  }
-  return results;
-}

+ 0 - 50
src/tools/quota/command.ts

@@ -1,50 +0,0 @@
-import * as path from "path";
-import * as os from "os";
-import * as fs from "fs";
-import { log } from "../../shared/logger";
-
-// Define base configuration directory based on OS
-const isWindows = os.platform() === "win32";
-const configBase = isWindows
-  ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
-  : path.join(os.homedir(), ".config", "opencode");
-
-const commandDir = path.join(configBase, "command");
-const commandFile = path.join(commandDir, "antigravity-quota.md");
-
-const commandContent = `---
-description: Check Antigravity quota status for all configured Google accounts
----
-
-Use the \`antigravity_quota\` tool to check the current quota status.
-
-This will show:
-- API quota remaining for each model (Gemini 3 Pro, Flash, Claude via Antigravity)
-- Per-account breakdown with compact display
-- Time until quota reset
-
-Just call the tool directly:
-\`\`\`
-antigravity_quota()
-\`\`\`
-
-IMPORTANT: Display the tool output EXACTLY as it is returned. Do not summarize, reformat, or modify the output in any way.
-`;
-
-// Try to create the command file for OpenCode context
-try {
-  if (!fs.existsSync(commandDir)) {
-    fs.mkdirSync(commandDir, { recursive: true });
-  }
-  if (!fs.existsSync(commandFile)) {
-    fs.writeFileSync(commandFile, commandContent, "utf-8");
-  } else {
-    const currentContent = fs.readFileSync(commandFile, "utf-8");
-    if (currentContent.includes("model: opencode/grok-code")) {
-      fs.writeFileSync(commandFile, commandContent, "utf-8");
-    }
-  }
-} catch (error) {
-  log("Failed to create command file/directory:", error);
-  // Continue execution, as this might not be fatal for the plugin's core function
-}

+ 219 - 104
src/tools/quota/index.ts

@@ -1,124 +1,239 @@
+import * as path from "path";
+import * as os from "os";
+import * as fs from "fs";
 import { tool } from "@opencode-ai/plugin";
-import { loadAccountsConfig, fetchAllQuotas, CONFIG_PATHS } from "./api";
-import type { ModelQuota } from "./types";
-
-/**
- * Compact quota display tool - groups models by quota family
- * 
- * Output format:
- * ```
- * tornikevault
- *   Claude   [░░░░░░░░░░]   0%  3h23m
- *   G-Flash  [██████████] 100%  4h59m
- *   G-Pro    [██████████] 100%  4h59m
- * 
- * tzedgin
- *   Claude   [░░░░░░░░░░]   0%  1h41m
- *   G-Flash  [██████████] 100%  4h59m
- *   G-Pro    [██████████] 100%  4h59m
- * ```
- */
+import { sleep } from "../../shared";
+import { log } from "../../shared/logger";
+
+// --- Types ---
+export interface Account {
+  email: string;
+  refreshToken: string;
+  projectId?: string;
+  managedProjectId?: string;
+  rateLimitResetTimes: Record<string, number>;
+}
+
+export interface AccountsConfig {
+  accounts: Account[];
+  activeIndex: number;
+}
+
+export interface QuotaInfo {
+  remainingFraction?: number;
+  resetTime?: string;
+}
+
+export interface ModelInfo {
+  displayName?: string;
+  model?: string;
+  quotaInfo?: QuotaInfo;
+  recommended?: boolean;
+}
+
+export interface QuotaResponse {
+  models?: Record<string, ModelInfo>;
+}
+
+export interface TokenResponse {
+  access_token: string;
+}
+
+export interface LoadCodeAssistResponse {
+  cloudaicompanionProject?: unknown;
+}
+
+export interface ModelQuota {
+  name: string;
+  percent: number;
+  resetIn: string;
+}
+
+export interface AccountQuotaResult {
+  email: string;
+  success: boolean;
+  error?: string;
+  models: ModelQuota[];
+}
+
+// --- Constants & API ---
+const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
+const CLOUDCODE_BASE_URL = "https://cloudcode-pa.googleapis.com";
+const CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
+const CLIENT_SECRET = "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";
+const DEFAULT_RESET_MS = 86_400_000;
+const ACCOUNT_FETCH_DELAY_MS = 200;
+const CLOUDCODE_METADATA = { ideType: "ANTIGRAVITY", platform: "PLATFORM_UNSPECIFIED", pluginType: "GEMINI" };
+
+const isWindows = os.platform() === "win32";
+const configBase = isWindows
+  ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
+  : path.join(os.homedir(), ".config", "opencode");
+
+const xdgData = process.env.XDG_DATA_HOME || path.join(os.homedir(), ".local", "share");
+const dataBase = isWindows ? configBase : path.join(xdgData, "opencode");
+
+export const CONFIG_PATHS = [
+  path.join(configBase, "antigravity-accounts.json"),
+  path.join(dataBase, "antigravity-accounts.json"),
+];
+
+// Command file generation logic
+const commandDir = path.join(configBase, "command");
+const commandFile = path.join(commandDir, "antigravity-quota.md");
+const commandContent = `---
+description: Check Antigravity quota status for all configured Google accounts
+---
+
+Use the \`antigravity_quota\` tool to check the current quota status.
+
+This will show:
+- API quota remaining for each model (Gemini 3 Pro, Flash, Claude via Antigravity)
+- Per-account breakdown with compact display
+- Time until quota reset
+
+Just call the tool directly:
+\`\`\`
+antigravity_quota()
+\`\`\`
+
+IMPORTANT: Display the tool output EXACTLY as it is returned. Do not summarize, reformat, or modify the output in any way.
+`;
+
+try {
+  if (!fs.existsSync(commandDir)) fs.mkdirSync(commandDir, { recursive: true });
+  if (!fs.existsSync(commandFile)) {
+    fs.writeFileSync(commandFile, commandContent, "utf-8");
+  } else {
+    const current = fs.readFileSync(commandFile, "utf-8");
+    if (current.includes("model: opencode/grok-code")) {
+      fs.writeFileSync(commandFile, commandContent, "utf-8");
+    }
+  }
+} catch (e) {
+  log("Failed to create command file:", e);
+}
+
+export function loadAccountsConfig(): AccountsConfig | null {
+  for (const p of CONFIG_PATHS) {
+    if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8")) as AccountsConfig;
+  }
+  return null;
+}
+
+async function refreshToken(rt: string): Promise<string> {
+  const params = new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, refresh_token: rt, grant_type: "refresh_token" });
+  const res = await fetch(GOOGLE_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: params.toString() });
+  if (!res.ok) throw new Error(`Token refresh failed (${res.status})`);
+  return ((await res.json()) as TokenResponse).access_token;
+}
+
+async function loadCodeAssist(token: string): Promise<LoadCodeAssistResponse> {
+  const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:loadCodeAssist`, {
+    method: "POST",
+    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": "antigravity" },
+    body: JSON.stringify({ metadata: CLOUDCODE_METADATA }),
+  });
+  if (!res.ok) throw new Error(`loadCodeAssist failed (${res.status})`);
+  return (await res.json()) as LoadCodeAssistResponse;
+}
+
+async function fetchModels(token: string, projectId?: string): Promise<QuotaResponse> {
+  const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:fetchAvailableModels`, {
+    method: "POST",
+    headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", "User-Agent": "antigravity" },
+    body: JSON.stringify(projectId ? { project: projectId } : {}),
+  });
+  if (!res.ok) throw new Error(`fetchModels failed (${res.status})`);
+  return (await res.json()) as QuotaResponse;
+}
+
+export async function fetchAccountQuota(account: Account): Promise<AccountQuotaResult> {
+  try {
+    const token = await refreshToken(account.refreshToken);
+    let pid = account.projectId || account.managedProjectId;
+    if (!pid) pid = (await loadCodeAssist(token)).cloudaicompanionProject as string;
+    
+    const quotaRes = await fetchModels(token, pid);
+    if (!quotaRes.models) return { email: account.email, success: true, models: [] };
+
+    const now = Date.now();
+    const models: ModelQuota[] = [];
+    for (const [key, info] of Object.entries(quotaRes.models)) {
+      if (!info.quotaInfo) continue;
+      const label = info.displayName || key;
+      const lower = label.toLowerCase();
+      if (["chat_", "rev19", "gemini 2.5", "gemini 3 pro image"].some(p => lower.includes(p))) continue;
+
+      let resetMs = DEFAULT_RESET_MS;
+      if (info.quotaInfo.resetTime) {
+        const parsed = new Date(info.quotaInfo.resetTime).getTime();
+        if (!isNaN(parsed)) resetMs = Math.max(0, parsed - now);
+      }
+
+      models.push({
+        name: label,
+        percent: Math.min(100, Math.max(0, (info.quotaInfo.remainingFraction ?? 0) * 100)),
+        resetIn: ((ms) => {
+          const s = Math.floor(Math.abs(ms) / 1000);
+          const h = Math.floor(s / 3600);
+          const m = Math.floor((s % 3600) / 60);
+          return h > 0 ? `${h}h${m}m` : `${m}m`;
+        })(resetMs),
+      });
+    }
+    models.sort((a, b) => a.name.localeCompare(b.name));
+    return { email: account.email, success: true, models };
+  } catch (err) {
+    return { email: account.email, success: false, error: String(err), models: [] };
+  }
+}
+
+// --- Tool Definition ---
 export const antigravity_quota = tool({
   description: "Check Antigravity API quota for all accounts (compact view with progress bars)",
   args: {},
   async execute() {
     try {
-      const config = await loadAccountsConfig();
-      if (!config) {
-        return `No accounts found. Checked:\n${CONFIG_PATHS.map((p) => `  - ${p}`).join("\n")}`;
-      }
+      const config = loadAccountsConfig();
+      if (!config) return `No accounts found. Checked:\n${CONFIG_PATHS.map((p) => `  - ${p}`).join("\n")}`;
 
-      // Create accounts with default emails if missing (don't mutate original)
-      const accounts = config.accounts.map((acc, i) => ({
-        ...acc,
-        email: acc.email || `account-${i + 1}`,
-      }));
+      const results: AccountQuotaResult[] = [];
+      for (let i = 0; i < config.accounts.length; i++) {
+        if (i > 0) await sleep(ACCOUNT_FETCH_DELAY_MS);
+        results.push(await fetchAccountQuota({ ...config.accounts[i], email: config.accounts[i].email || `account-${i + 1}` }));
+      }
 
-      const results = await fetchAllQuotas(accounts);
-      const errors: string[] = [];
       const blocks: string[] = [];
-
-      for (const result of results) {
-        if (!result.success) {
-          errors.push(`${shortEmail(result.email)}: ${result.error}`);
-          continue;
-        }
-
-        const email = shortEmail(result.email);
-        
-        if (result.models.length === 0) {
-          blocks.push(`${email}\n  (no models)`);
+      for (const res of results) {
+        if (!res.success) {
+          blocks.push(`${res.email.split("@")[0]}: ${res.error}`);
           continue;
         }
-
-        // Group models by quota family
-        const grouped = groupByFamily(result.models);
-        const lines = [email];
-        
-        for (const [family, model] of Object.entries(grouped)) {
-          if (model) {
-            const name = family.padEnd(8);
-            const bar = progressBar(model.percent);
-            const pct = model.percent.toFixed(0).padStart(3);
-            lines.push(`  ${name} ${bar} ${pct}%  ${model.resetIn}`);
+        const lines = [res.email.split("@")[0]];
+        if (res.models.length === 0) {
+          lines.push("  (no models)");
+        } else {
+          const families: Record<string, ModelQuota | null> = { "Claude": null, "G-Flash": null, "G-Pro": null };
+          for (const m of res.models) {
+            const l = m.name.toLowerCase();
+            if (l.includes("claude") || l.includes("opus") || l.includes("sonnet") || l.includes("gpt")) { if (!families["Claude"]) families["Claude"] = m; }
+            else if (l.includes("flash")) { if (!families["G-Flash"]) families["G-Flash"] = m; }
+            else if (l.includes("gemini") || l.includes("pro")) { if (!families["G-Pro"]) families["G-Pro"] = m; }
+          }
+          for (const [fam, m] of Object.entries(families)) {
+            if (m) {
+              const bar = `[${"█".repeat(Math.round((m.percent / 100) * 10))}${"░".repeat(10 - Math.round((m.percent / 100) * 10))}]`;
+              lines.push(`  ${fam.padEnd(8)} ${bar} ${m.percent.toFixed(0).padStart(3)}%  ${m.resetIn}`);
+            }
           }
         }
-
         blocks.push(lines.join("\n"));
       }
 
-      let output = "# Quota\n```\n";
-      if (errors.length > 0) {
-        output += `Errors: ${errors.join(", ")}\n\n`;
-      }
-      output += blocks.join("\n\n");
-      output += "\n```";
-      output += "\n\n<!-- DISPLAY THIS OUTPUT EXACTLY AS-IS. DO NOT REFORMAT, SUMMARIZE, OR ADD TABLES. -->";
-
-      return output;
+      return `# Quota\n\`\`\`\n${blocks.join("\n\n")}\n\`\`\`\n\n<!-- DISPLAY THIS OUTPUT EXACTLY AS-IS. DO NOT REFORMAT, SUMMARIZE, OR ADD TABLES. -->`;
     } catch (err) {
-      return `Error: ${err instanceof Error ? err.message : String(err)}`;
+      return `Error: ${String(err)}`;
     }
   },
 });
-
-// Group models into 3 families: Claude (opus/sonnet/gpt), G-Flash, G-Pro
-function groupByFamily(models: ModelQuota[]): Record<string, ModelQuota | null> {
-  const families: Record<string, ModelQuota | null> = {
-    "Claude": null,
-    "G-Flash": null,
-    "G-Pro": null,
-  };
-
-  for (const m of models) {
-    const lower = m.name.toLowerCase();
-    
-    // Claude family: opus, sonnet, gpt-oss share quota
-    if (lower.includes("claude") || lower.includes("opus") || lower.includes("sonnet") || lower.includes("gpt")) {
-      if (!families["Claude"]) families["Claude"] = m;
-    }
-    // Gemini Flash - dedicated quota
-    else if (lower.includes("flash")) {
-      if (!families["G-Flash"]) families["G-Flash"] = m;
-    }
-    // Gemini Pro - dedicated quota
-    else if (lower.includes("gemini") || lower.includes("pro")) {
-      if (!families["G-Pro"]) families["G-Pro"] = m;
-    }
-  }
-
-  return families;
-}
-
-// ASCII progress bar
-function progressBar(percent: number): string {
-  const width = 10;
-  const filled = Math.round((percent / 100) * width);
-  const empty = width - filled;
-  return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
-}
-
-// Shorten email to username part
-function shortEmail(email: string): string {
-  return email.split("@")[0] ?? email;
-}

+ 0 - 49
src/tools/quota/types.ts

@@ -1,49 +0,0 @@
-export interface Account {
-  email: string;
-  refreshToken: string;
-  projectId?: string;
-  managedProjectId?: string;
-  rateLimitResetTimes: Record<string, number>;
-}
-
-export interface AccountsConfig {
-  accounts: Account[];
-  activeIndex: number;
-}
-
-export interface QuotaInfo {
-  remainingFraction?: number;
-  resetTime?: string;
-}
-
-export interface ModelInfo {
-  displayName?: string;
-  model?: string;
-  quotaInfo?: QuotaInfo;
-  recommended?: boolean;
-}
-
-export interface QuotaResponse {
-  models?: Record<string, ModelInfo>;
-}
-
-export interface TokenResponse {
-  access_token: string;
-}
-
-export interface LoadCodeAssistResponse {
-  cloudaicompanionProject?: unknown;
-}
-
-export interface ModelQuota {
-  name: string;
-  percent: number;
-  resetIn: string;
-}
-
-export interface AccountQuotaResult {
-  email: string;
-  success: boolean;
-  error?: string;
-  models: ModelQuota[];
-}

+ 0 - 3
src/utils/index.ts

@@ -1,3 +0,0 @@
-export * from "./polling";
-export * from "./tmux";
-export * from "./agent-variant";