Procházet zdrojové kódy

Prompt overriding (#76)

* readme

* Add prompts overriding

* Add tests

* Biome

* Update readme
Alvin před 7 měsíci
rodič
revize
9ff803fd54
99 změnil soubory, kde provedl 6695 přidání a 5277 odebrání
  1. 44 18
      README.md
  2. 30 0
      biome.json
  3. 19 0
      bun.lock
  4. 5 0
      package.json
  5. 19 6
      src/agents/designer.ts
  6. 18 6
      src/agents/explorer.ts
  7. 18 5
      src/agents/fixer.ts
  8. 89 85
      src/agents/index.test.ts
  9. 88 39
      src/agents/index.ts
  10. 19 6
      src/agents/librarian.ts
  11. 18 6
      src/agents/oracle.ts
  12. 19 6
      src/agents/orchestrator.ts
  13. 260 212
      src/background/background-manager.test.ts
  14. 91 55
      src/background/background-manager.ts
  15. 6 2
      src/background/index.ts
  16. 141 133
      src/background/tmux-session-manager.test.ts
  17. 49 32
      src/background/tmux-session-manager.ts
  18. 203 170
      src/cli/config-io.test.ts
  19. 168 125
      src/cli/config-io.ts
  20. 95 91
      src/cli/config-manager.test.ts
  21. 4 4
      src/cli/config-manager.ts
  22. 30 30
      src/cli/index.ts
  23. 208 151
      src/cli/install.ts
  24. 92 91
      src/cli/paths.test.ts
  25. 17 20
      src/cli/paths.ts
  26. 62 54
      src/cli/providers.test.ts
  27. 62 50
      src/cli/providers.ts
  28. 40 35
      src/cli/system.test.ts
  29. 30 28
      src/cli/system.ts
  30. 21 21
      src/cli/types.ts
  31. 13 13
      src/config/constants.ts
  32. 3 3
      src/config/index.ts
  33. 510 407
      src/config/loader.test.ts
  34. 92 26
      src/config/loader.ts
  35. 9 9
      src/config/schema.ts
  36. 57 55
      src/hooks/auto-update-checker/cache.test.ts
  37. 55 48
      src/hooks/auto-update-checker/cache.ts
  38. 114 104
      src/hooks/auto-update-checker/checker.test.ts
  39. 133 109
      src/hooks/auto-update-checker/checker.ts
  40. 16 16
      src/hooks/auto-update-checker/constants.ts
  41. 149 78
      src/hooks/auto-update-checker/index.ts
  42. 13 13
      src/hooks/auto-update-checker/types.ts
  43. 4 4
      src/hooks/index.ts
  44. 12 11
      src/hooks/phase-reminder/index.ts
  45. 5 4
      src/hooks/post-read-nudge/index.ts
  46. 58 32
      src/index.ts
  47. 4 4
      src/mcp/context7.ts
  48. 3 3
      src/mcp/grep-app.ts
  49. 93 93
      src/mcp/index.test.ts
  50. 10 8
      src/mcp/index.ts
  51. 2 2
      src/mcp/types.ts
  52. 4 4
      src/mcp/websearch.ts
  53. 122 99
      src/tools/ast-grep/cli.ts
  54. 109 103
      src/tools/ast-grep/constants.ts
  55. 67 63
      src/tools/ast-grep/downloader.ts
  56. 19 10
      src/tools/ast-grep/index.ts
  57. 57 40
      src/tools/ast-grep/tools.ts
  58. 41 41
      src/tools/ast-grep/types.ts
  59. 67 48
      src/tools/ast-grep/utils.ts
  60. 423 307
      src/tools/background.test.ts
  61. 118 60
      src/tools/background.ts
  62. 111 93
      src/tools/grep/cli.ts
  63. 77 67
      src/tools/grep/constants.ts
  64. 89 70
      src/tools/grep/downloader.ts
  65. 8 5
      src/tools/grep/index.ts
  66. 23 17
      src/tools/grep/tools.ts
  67. 27 27
      src/tools/grep/types.ts
  68. 14 14
      src/tools/grep/utils.ts
  69. 11 12
      src/tools/index.ts
  70. 68 37
      src/tools/lsp/client.test.ts
  71. 220 189
      src/tools/lsp/client.ts
  72. 79 57
      src/tools/lsp/config.test.ts
  73. 30 28
      src/tools/lsp/config.ts
  74. 111 102
      src/tools/lsp/constants.ts
  75. 7 7
      src/tools/lsp/index.ts
  76. 99 72
      src/tools/lsp/tools.ts
  77. 46 24
      src/tools/lsp/types.ts
  78. 129 119
      src/tools/lsp/utils.test.ts
  79. 177 131
      src/tools/lsp/utils.ts
  80. 63 44
      src/tools/quota/api.ts
  81. 14 14
      src/tools/quota/command.ts
  82. 37 28
      src/tools/quota/index.ts
  83. 172 168
      src/tools/skill/builtin.test.ts
  84. 27 23
      src/tools/skill/builtin.ts
  85. 5 5
      src/tools/skill/index.ts
  86. 11 11
      src/tools/skill/mcp-manager.test.ts
  87. 49 36
      src/tools/skill/mcp-manager.ts
  88. 86 59
      src/tools/skill/tools.ts
  89. 8 8
      src/tools/skill/types.ts
  90. 59 59
      src/utils/agent-variant.test.ts
  91. 6 6
      src/utils/agent-variant.ts
  92. 5 5
      src/utils/index.ts
  93. 122 120
      src/utils/logger.test.ts
  94. 7 7
      src/utils/logger.ts
  95. 171 163
      src/utils/polling.test.ts
  96. 3 3
      src/utils/polling.ts
  97. 25 25
      src/utils/tmux.test.ts
  98. 97 74
      src/utils/tmux.ts
  99. 55 50
      src/utils/zip-extractor.ts

+ 44 - 18
README.md

@@ -52,6 +52,7 @@
 - [🔌 **MCP Servers**](#mcp-servers)
 - [⚙️ **Configuration**](#configuration)
   - [Files You Edit](#files-you-edit)
+  - [Prompt Overriding](#prompt-overriding)
   - [Plugin Config](#plugin-config-oh-my-opencode-slimjson)
     - [Presets](#presets)
     - [Option Reference](#option-reference)
@@ -281,9 +282,7 @@ Code implementation, refactoring, testing, verification. *Execute the plan - no
 
 ### Tmux Integration
 
-> ⚠️ **Temporary workaround:** Start OpenCode with `--port` to enable tmux integration. The port must match the `OPENCODE_PORT` environment variable (default: 4096). This is required until the upstream issue is resolved.
-
-> ⚠️ **Known Issue:** When the server port is enabled, only one OpenCode instance can be opened at a time. We're tracking this in [issue #15](https://github.com/alvinunreal/oh-my-opencode-slim/issues/15), and there's an upstream PR to OpenCode: [opencode#9099](https://github.com/anomalyco/opencode/issues/9099).
+> ⚠️ **Temporary workaround:** Start OpenCode with `--port` to enable tmux integration. The port must match the `OPENCODE_PORT` environment variable (default: 4096). This is required until the upstream issue is resolved. [opencode#9099](https://github.com/anomalyco/opencode/issues/9099).
 
 <img src="img/tmux.png" alt="Tmux Integration" width="800">
 
@@ -292,6 +291,17 @@ Code implementation, refactoring, testing, verification. *Execute the plan - no
 #### Quick Setup
 
 1. **Enable tmux integration** in `oh-my-opencode-slim.json` (see [Plugin Config](#plugin-config-oh-my-opencode-slimjson)).
+
+  ```json
+  {
+    "tmux": {
+      "enabled": true,
+      "layout": "main-vertical",
+      "main_pane_size": 60
+    }
+  }
+  ```
+
 2. **Run OpenCode inside tmux**:
     ```bash
     tmux
@@ -307,19 +317,6 @@ Code implementation, refactoring, testing, verification. *Execute the plan - no
 
    This allows multiple OpenCode instances on different ports.
 
-#### Configuration
-
-Add this to your `oh-my-opencode-slim.json`:
-
-```json
-{
-  "tmux": {
-    "enabled": true,
-    "layout": "main-vertical",
-    "main_pane_size": 60
-  }
-}
-```
 
 #### Layout Options
 
@@ -331,8 +328,6 @@ Add this to your `oh-my-opencode-slim.json`:
 | `even-horizontal` | All panes side by side |
 | `even-vertical` | All panes stacked vertically |
 
-*See the [Option Reference](#option-reference) for detailed configuration.*
-
 ---
 
 ### Quota Tool
@@ -477,6 +472,37 @@ You can disable specific MCP servers by adding them to the `disabled_mcps` array
 
 ---
 
+### Prompt Overriding
+
+You can customize agent prompts by creating markdown files in `~/.config/opencode/oh-my-opencode-slim/`:
+
+| File | Purpose |
+|------|---------|
+| `{agent}.md` | Replaces the default prompt entirely |
+| `{agent}_append.md` | Appends to the default prompt |
+
+**Example:**
+
+```
+~/.config/opencode/oh-my-opencode-slim/
+  ├── orchestrator.md          # Custom orchestrator prompt
+  ├── orchestrator_append.md   # Append to default orchestrator prompt
+  ├── explorer.md
+  ├── explorer_append.md
+  └── ...
+```
+
+**Usage:**
+
+- Create `{agent}.md` to completely replace an agent's default prompt
+- Create `{agent}_append.md` to add custom instructions to the default prompt
+- Both files can exist simultaneously - the replacement takes precedence
+- If neither file exists, the default prompt is used
+
+This allows you to fine-tune agent behavior without modifying the source code.
+
+---
+
 ### Plugin Config (`oh-my-opencode-slim.json`)
 
 The installer generates this file based on your providers. You can manually customize it to mix and match models.

+ 30 - 0
biome.json

@@ -0,0 +1,30 @@
+{
+  "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
+  "assist": { "actions": { "source": { "organizeImports": "on" } } },
+  "vcs": {
+    "enabled": true,
+    "clientKind": "git",
+    "useIgnoreFile": true
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "recommended": true
+    }
+  },
+  "formatter": {
+    "enabled": true,
+    "formatWithErrors": false,
+    "indentStyle": "space",
+    "indentWidth": 2,
+    "lineEnding": "lf",
+    "lineWidth": 80,
+    "attributePosition": "auto"
+  },
+  "javascript": {
+    "formatter": {
+      "quoteStyle": "single",
+      "trailingCommas": "all"
+    }
+  }
+}

+ 19 - 0
bun.lock

@@ -14,6 +14,7 @@
         "zod": "^4.1.8",
       },
       "devDependencies": {
+        "@biomejs/biome": "2.3.11",
         "bun-types": "latest",
         "typescript": "^5.7.3",
       },
@@ -39,6 +40,24 @@
 
     "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/MJ5un7yxlClaaxou9eYl+Kr2xr/yTtYtTq5aLBWjPWA6dmmJ1nAJgx5zKHVuplFXFBrFDQk3paEgAETMTGcrA=="],
 
+    "@biomejs/biome": ["@biomejs/biome@2.3.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.11", "@biomejs/cli-darwin-x64": "2.3.11", "@biomejs/cli-linux-arm64": "2.3.11", "@biomejs/cli-linux-arm64-musl": "2.3.11", "@biomejs/cli-linux-x64": "2.3.11", "@biomejs/cli-linux-x64-musl": "2.3.11", "@biomejs/cli-win32-arm64": "2.3.11", "@biomejs/cli-win32-x64": "2.3.11" }, "bin": { "biome": "bin/biome" } }, "sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ=="],
+
+    "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA=="],
+
+    "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.3.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg=="],
+
+    "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g=="],
+
+    "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.3.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg=="],
+
+    "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg=="],
+
+    "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.3.11", "", { "os": "linux", "cpu": "x64" }, "sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw=="],
+
+    "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.3.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw=="],
+
+    "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg=="],
+
     "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
 
     "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],

+ 5 - 0
package.json

@@ -37,6 +37,10 @@
     "build": "bun build src/index.ts --outdir dist --target bun --format esm && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm && tsc --emitDeclarationOnly",
     "typecheck": "tsc --noEmit",
     "test": "bun test",
+    "lint": "biome lint .",
+    "format": "biome format . --write",
+    "check": "biome check --write .",
+    "check:ci": "biome check .",
     "dev": "bun run build && opencode",
     "prepublishOnly": "bun run build"
   },
@@ -50,6 +54,7 @@
     "zod": "^4.1.8"
   },
   "devDependencies": {
+    "@biomejs/biome": "2.3.11",
     "bun-types": "latest",
     "typescript": "^5.7.3"
   },

+ 19 - 6
src/agents/designer.ts

@@ -1,4 +1,4 @@
-import type { AgentDefinition } from "./orchestrator";
+import type { AgentDefinition } from './orchestrator';
 
 const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX engineer.
 
@@ -13,14 +13,27 @@ const DESIGNER_PROMPT = `You are a Designer - a frontend UI/UX engineer.
 - Use existing component libraries when available
 - Prioritize visual excellence over code perfection`;
 
-export function createDesignerAgent(model: string): AgentDefinition {
+export function createDesignerAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = DESIGNER_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${DESIGNER_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "designer",
-    description: "UI/UX design and implementation. Use for styling, responsive design, component architecture and visual polish.",
+    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,
+      prompt,
     },
   };
-}
+}

+ 18 - 6
src/agents/explorer.ts

@@ -1,4 +1,4 @@
-import type { AgentDefinition } from "./orchestrator";
+import type { AgentDefinition } from './orchestrator';
 
 const EXPLORER_PROMPT = `You are Explorer - a fast codebase navigation specialist.
 
@@ -39,15 +39,27 @@ Concise answer to the question
 - Be exhaustive but concise
 - Include line numbers when relevant`;
 
-export function createExplorerAgent(model: string): AgentDefinition {
+export function createExplorerAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = EXPLORER_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${EXPLORER_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "explorer",
-    description: "Fast codebase search and pattern matching. Use for finding files, locating code patterns, and answering 'where is X?' questions.",
+    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,
+      prompt,
     },
   };
 }
-

+ 18 - 5
src/agents/fixer.ts

@@ -1,4 +1,4 @@
-import type { AgentDefinition } from "./orchestrator";
+import type { AgentDefinition } from './orchestrator';
 
 const FIXER_PROMPT = `You are Fixer - a fast, focused implementation specialist.
 
@@ -40,14 +40,27 @@ No changes required
 - LSP diagnostics: [not run - reason]
 </verification>`;
 
-export function createFixerAgent(model: string): AgentDefinition {
+export function createFixerAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = FIXER_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${FIXER_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "fixer",
-    description: "Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.",
+    name: 'fixer',
+    description:
+      'Fast implementation specialist. Receives complete context and task spec, executes code changes efficiently.',
     config: {
       model,
       temperature: 0.2,
-      prompt: FIXER_PROMPT,
+      prompt,
     },
   };
 }

+ 89 - 85
src/agents/index.test.ts

@@ -1,190 +1,194 @@
-import { describe, expect, test } from "bun:test";
-import { createAgents, getAgentConfigs, isSubagent } from "./index";
-import { SUBAGENT_NAMES } from "../config";
-import type { PluginConfig } from "../config";
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { SUBAGENT_NAMES } from '../config';
+import { createAgents, getAgentConfigs, isSubagent } from './index';
 
-describe("agent alias backward compatibility", () => {
+describe('agent alias backward compatibility', () => {
   test("applies 'explore' config to 'explorer' agent", () => {
     const config: PluginConfig = {
       agents: {
-        explore: { model: "test/old-explore-model" },
+        explore: { model: 'test/old-explore-model' },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
+    const explorer = agents.find((a) => a.name === 'explorer');
     expect(explorer).toBeDefined();
-    expect(explorer!.config.model).toBe("test/old-explore-model");
+    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" },
+        'frontend-ui-ux-engineer': { model: 'test/old-frontend-model' },
       },
     };
     const agents = createAgents(config);
-    const designer = agents.find((a) => a.name === "designer");
+    const designer = agents.find((a) => a.name === 'designer');
     expect(designer).toBeDefined();
-    expect(designer!.config.model).toBe("test/old-frontend-model");
+    expect(designer?.config.model).toBe('test/old-frontend-model');
   });
 
-  test("new name takes priority over old alias", () => {
+  test('new name takes priority over old alias', () => {
     const config: PluginConfig = {
       agents: {
-        explore: { model: "old-model" },
-        explorer: { model: "new-model" },
+        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");
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.config.model).toBe('new-model');
   });
 
-  test("new agent names work directly", () => {
+  test('new agent names work directly', () => {
     const config: PluginConfig = {
       agents: {
-        explorer: { model: "direct-explorer" },
-        designer: { model: "direct-designer" },
+        explorer: { model: 'direct-explorer' },
+        designer: { model: 'direct-designer' },
       },
     };
     const agents = createAgents(config);
-    expect(agents.find((a) => a.name === "explorer")!.config.model).toBe("direct-explorer");
-    expect(agents.find((a) => a.name === "designer")!.config.model).toBe("direct-designer");
+    expect(agents.find((a) => a.name === 'explorer')?.config.model).toBe(
+      'direct-explorer',
+    );
+    expect(agents.find((a) => a.name === 'designer')?.config.model).toBe(
+      'direct-designer',
+    );
   });
 
-  test("temperature override via old alias", () => {
+  test('temperature override via old alias', () => {
     const config: PluginConfig = {
       agents: {
         explore: { temperature: 0.5 },
       },
     };
     const agents = createAgents(config);
-    const explorer = agents.find((a) => a.name === "explorer");
-    expect(explorer!.config.temperature).toBe(0.5);
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?.config.temperature).toBe(0.5);
   });
 });
 
-describe("fixer agent fallback", () => {
-  test("fixer inherits librarian model when no fixer config provided", () => {
+describe('fixer agent fallback', () => {
+  test('fixer inherits librarian model when no fixer config provided', () => {
     const config: PluginConfig = {
       agents: {
-        librarian: { model: "librarian-custom-model" },
+        librarian: { model: 'librarian-custom-model' },
       },
     };
     const agents = createAgents(config);
-    const fixer = agents.find((a) => a.name === "fixer");
-    const librarian = agents.find((a) => a.name === "librarian");
-    expect(fixer!.config.model).toBe(librarian!.config.model);
+    const fixer = agents.find((a) => a.name === 'fixer');
+    const librarian = agents.find((a) => a.name === 'librarian');
+    expect(fixer?.config.model).toBe(librarian?.config.model);
   });
 
-  test("fixer uses its own model when explicitly configured", () => {
+  test('fixer uses its own model when explicitly configured', () => {
     const config: PluginConfig = {
       agents: {
-        librarian: { model: "librarian-model" },
-        fixer: { model: "fixer-specific-model" },
+        librarian: { model: 'librarian-model' },
+        fixer: { model: 'fixer-specific-model' },
       },
     };
     const agents = createAgents(config);
-    const fixer = agents.find((a) => a.name === "fixer");
-    expect(fixer!.config.model).toBe("fixer-specific-model");
+    const fixer = agents.find((a) => a.name === 'fixer');
+    expect(fixer?.config.model).toBe('fixer-specific-model');
   });
 });
 
-describe("orchestrator agent", () => {
-  test("orchestrator is first in agents array", () => {
+describe('orchestrator agent', () => {
+  test('orchestrator is first in agents array', () => {
     const agents = createAgents();
-    expect(agents[0].name).toBe("orchestrator");
+    expect(agents[0].name).toBe('orchestrator');
   });
 
-  test("orchestrator has question permission set to allow", () => {
+  test('orchestrator has question permission set to allow', () => {
     const agents = createAgents();
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
-    expect(orchestrator!.config.permission).toBeDefined();
-    expect((orchestrator!.config.permission as any).question).toBe("allow");
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.permission).toBeDefined();
+    expect((orchestrator?.config.permission as any).question).toBe('allow');
   });
 
-  test("orchestrator accepts overrides", () => {
+  test('orchestrator accepts overrides', () => {
     const config: PluginConfig = {
       agents: {
-        orchestrator: { model: "custom-orchestrator-model", temperature: 0.3 },
+        orchestrator: { model: 'custom-orchestrator-model', temperature: 0.3 },
       },
     };
     const agents = createAgents(config);
-    const orchestrator = agents.find((a) => a.name === "orchestrator");
-    expect(orchestrator!.config.model).toBe("custom-orchestrator-model");
-    expect(orchestrator!.config.temperature).toBe(0.3);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?.config.model).toBe('custom-orchestrator-model');
+    expect(orchestrator?.config.temperature).toBe(0.3);
   });
 });
 
-describe("isSubagent type guard", () => {
-  test("returns true for valid subagent names", () => {
-    expect(isSubagent("explorer")).toBe(true);
-    expect(isSubagent("librarian")).toBe(true);
-    expect(isSubagent("oracle")).toBe(true);
-    expect(isSubagent("designer")).toBe(true);
-    expect(isSubagent("fixer")).toBe(true);
+describe('isSubagent type guard', () => {
+  test('returns true for valid subagent names', () => {
+    expect(isSubagent('explorer')).toBe(true);
+    expect(isSubagent('librarian')).toBe(true);
+    expect(isSubagent('oracle')).toBe(true);
+    expect(isSubagent('designer')).toBe(true);
+    expect(isSubagent('fixer')).toBe(true);
   });
 
-  test("returns false for orchestrator", () => {
-    expect(isSubagent("orchestrator")).toBe(false);
+  test('returns false for orchestrator', () => {
+    expect(isSubagent('orchestrator')).toBe(false);
   });
 
-  test("returns false for invalid agent names", () => {
-    expect(isSubagent("invalid-agent")).toBe(false);
-    expect(isSubagent("")).toBe(false);
-    expect(isSubagent("explore")).toBe(false); // old alias, not actual agent name
+  test('returns false for invalid agent names', () => {
+    expect(isSubagent('invalid-agent')).toBe(false);
+    expect(isSubagent('')).toBe(false);
+    expect(isSubagent('explore')).toBe(false); // old alias, not actual agent name
   });
 });
 
-describe("agent classification", () => {
-  test("SUBAGENT_NAMES excludes orchestrator", () => {
-    expect(SUBAGENT_NAMES).not.toContain("orchestrator");
-    expect(SUBAGENT_NAMES).toContain("explorer");
-    expect(SUBAGENT_NAMES).toContain("fixer");
+describe('agent classification', () => {
+  test('SUBAGENT_NAMES excludes orchestrator', () => {
+    expect(SUBAGENT_NAMES).not.toContain('orchestrator');
+    expect(SUBAGENT_NAMES).toContain('explorer');
+    expect(SUBAGENT_NAMES).toContain('fixer');
   });
 
-  test("getAgentConfigs applies correct classification visibility and mode", () => {
+  test('getAgentConfigs applies correct classification visibility and mode', () => {
     const configs = getAgentConfigs();
 
     // Primary agent
-    expect(configs["orchestrator"].mode).toBe("primary");
+    expect(configs.orchestrator.mode).toBe('primary');
 
     // Subagents
     for (const name of SUBAGENT_NAMES) {
-      expect(configs[name].mode).toBe("subagent");
+      expect(configs[name].mode).toBe('subagent');
     }
   });
 });
 
-describe("createAgents", () => {
-  test("creates all agents without config", () => {
+describe('createAgents', () => {
+  test('creates all agents without config', () => {
     const agents = createAgents();
     const names = agents.map((a) => a.name);
-    expect(names).toContain("orchestrator");
-    expect(names).toContain("explorer");
-    expect(names).toContain("designer");
-    expect(names).toContain("oracle");
-    expect(names).toContain("librarian");
-    expect(names).toContain("fixer");
+    expect(names).toContain('orchestrator');
+    expect(names).toContain('explorer');
+    expect(names).toContain('designer');
+    expect(names).toContain('oracle');
+    expect(names).toContain('librarian');
+    expect(names).toContain('fixer');
   });
 
-  test("creates exactly 6 agents (1 primary + 5 subagents)", () => {
+  test('creates exactly 6 agents (1 primary + 5 subagents)', () => {
     const agents = createAgents();
     expect(agents.length).toBe(6);
   });
 });
 
-describe("getAgentConfigs", () => {
-  test("returns config record keyed by agent name", () => {
+describe('getAgentConfigs', () => {
+  test('returns config record keyed by agent name', () => {
     const configs = getAgentConfigs();
-    expect(configs["orchestrator"]).toBeDefined();
-    expect(configs["explorer"]).toBeDefined();
-    expect(configs["orchestrator"].model).toBeDefined();
+    expect(configs.orchestrator).toBeDefined();
+    expect(configs.explorer).toBeDefined();
+    expect(configs.orchestrator.model).toBeDefined();
   });
 
-  test("includes description in SDK config", () => {
+  test('includes description in SDK config', () => {
     const configs = getAgentConfigs();
-    expect(configs["orchestrator"].description).toBeDefined();
-    expect(configs["explorer"].description).toBeDefined();
+    expect(configs.orchestrator.description).toBeDefined();
+    expect(configs.explorer.description).toBeDefined();
   });
 });

+ 88 - 39
src/agents/index.ts

@@ -1,30 +1,48 @@
-import type { AgentConfig as SDKAgentConfig } from "@opencode-ai/sdk";
-import { DEFAULT_MODELS, SUBAGENT_NAMES, 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;
+import type { AgentConfig as SDKAgentConfig } from '@opencode-ai/sdk';
+import {
+  type AgentOverrideConfig,
+  DEFAULT_MODELS,
+  loadAgentPrompt,
+  type PluginConfig,
+  SUBAGENT_NAMES,
+} from '../config';
+import { createDesignerAgent } from './designer';
+import { createExplorerAgent } from './explorer';
+import { createFixerAgent } from './fixer';
+import { createLibrarianAgent } from './librarian';
+import { createOracleAgent } from './oracle';
+import { type AgentDefinition, createOrchestratorAgent } from './orchestrator';
+
+export type { AgentDefinition } from './orchestrator';
+
+type AgentFactory = (
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+) => AgentDefinition;
 
 // Backward Compatibility
 
 /** Map old agent names to new names for backward compatibility */
 const AGENT_ALIASES: Record<string, string> = {
-  "explore": "explorer",
-  "frontend-ui-ux-engineer": "designer",
+  explore: 'explorer',
+  'frontend-ui-ux-engineer': 'designer',
 };
 
 /**
  * Get agent override config by name, supporting backward-compatible aliases.
  * Checks both the current name and any legacy alias names.
  */
-function getOverride(overrides: Record<string, AgentOverrideConfig>, name: string): AgentOverrideConfig | undefined {
-  return overrides[name] ?? overrides[Object.keys(AGENT_ALIASES).find(k => AGENT_ALIASES[k] === name) ?? ""];
+function getOverride(
+  overrides: Record<string, AgentOverrideConfig>,
+  name: string,
+): AgentOverrideConfig | undefined {
+  return (
+    overrides[name] ??
+    overrides[
+      Object.keys(AGENT_ALIASES).find((k) => AGENT_ALIASES[k] === name) ?? ''
+    ]
+  );
 }
 
 // Agent Configuration Helpers
@@ -33,9 +51,13 @@ function getOverride(overrides: Record<string, AgentOverrideConfig>, name: strin
  * Apply user-provided overrides to an agent's configuration.
  * Supports overriding model and temperature.
  */
-function applyOverrides(agent: AgentDefinition, override: AgentOverrideConfig): void {
+function applyOverrides(
+  agent: AgentDefinition,
+  override: AgentOverrideConfig,
+): void {
   if (override.model) agent.config.model = override.model;
-  if (override.temperature !== undefined) agent.config.temperature = override.temperature;
+  if (override.temperature !== undefined)
+    agent.config.temperature = override.temperature;
 }
 
 /**
@@ -43,13 +65,19 @@ function applyOverrides(agent: AgentDefinition, override: AgentOverrideConfig):
  * Currently sets 'question' permission to 'allow' for all agents.
  */
 function applyDefaultPermissions(agent: AgentDefinition): void {
-  const existing = (agent.config.permission ?? {}) as Record<string, "ask" | "allow" | "deny">;
-  agent.config.permission = { ...existing, question: "allow" } as SDKAgentConfig["permission"];
+  const existing = (agent.config.permission ?? {}) as Record<
+    string,
+    'ask' | 'allow' | 'deny'
+  >;
+  agent.config.permission = {
+    ...existing,
+    question: 'allow',
+  } as SDKAgentConfig['permission'];
 }
 
 // Agent Classification
 
-export type SubagentName = typeof SUBAGENT_NAMES[number];
+export type SubagentName = (typeof SUBAGENT_NAMES)[number];
 
 export function isSubagent(name: string): name is SubagentName {
   return (SUBAGENT_NAMES as readonly string[]).includes(name);
@@ -70,7 +98,7 @@ const SUBAGENT_FACTORIES: Record<SubagentName, AgentFactory> = {
 /**
  * Create all agent definitions with optional configuration overrides.
  * Instantiates the orchestrator and all subagents, applying user config and defaults.
- * 
+ *
  * @param config - Optional plugin configuration with agent overrides
  * @returns Array of agent definitions (orchestrator first, then subagents)
  */
@@ -80,16 +108,26 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // 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"];
+    if (name === 'fixer' && !getOverride(agentOverrides, 'fixer')?.model) {
+      return (
+        getOverride(agentOverrides, 'librarian')?.model ??
+        DEFAULT_MODELS.librarian
+      );
     }
     return DEFAULT_MODELS[name];
   };
 
-  // 1. Gather all sub-agent definitions
-  const protoSubAgents = (Object.entries(SUBAGENT_FACTORIES) as [SubagentName, AgentFactory][]).map(
-    ([name, factory]) => factory(getModelForAgent(name))
-  );
+  // 1. Gather all sub-agent definitions with custom prompts
+  const protoSubAgents = (
+    Object.entries(SUBAGENT_FACTORIES) as [SubagentName, AgentFactory][]
+  ).map(([name, factory]) => {
+    const customPrompts = loadAgentPrompt(name);
+    return factory(
+      getModelForAgent(name),
+      customPrompts.prompt,
+      customPrompts.appendPrompt,
+    );
+  });
 
   // 2. Apply overrides to each agent
   const allSubAgents = protoSubAgents.map((agent) => {
@@ -100,12 +138,18 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     return agent;
   });
 
-  // 3. Create Orchestrator (with its own overrides)
+  // 3. Create Orchestrator (with its own overrides and custom prompts)
   const orchestratorModel =
-    getOverride(agentOverrides, "orchestrator")?.model ?? DEFAULT_MODELS["orchestrator"];
-  const orchestrator = createOrchestratorAgent(orchestratorModel);
+    getOverride(agentOverrides, 'orchestrator')?.model ??
+    DEFAULT_MODELS.orchestrator;
+  const orchestratorPrompts = loadAgentPrompt('orchestrator');
+  const orchestrator = createOrchestratorAgent(
+    orchestratorModel,
+    orchestratorPrompts.prompt,
+    orchestratorPrompts.appendPrompt,
+  );
   applyDefaultPermissions(orchestrator);
-  const oOverride = getOverride(agentOverrides, "orchestrator");
+  const oOverride = getOverride(agentOverrides, 'orchestrator');
   if (oOverride) {
     applyOverrides(orchestrator, oOverride);
   }
@@ -116,24 +160,29 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
 /**
  * Get agent configurations formatted for the OpenCode SDK.
  * Converts agent definitions to SDK config format and applies classification metadata.
- * 
+ *
  * @param config - Optional plugin configuration with agent overrides
  * @returns Record mapping agent names to their SDK configurations
  */
-export function getAgentConfigs(config?: PluginConfig): Record<string, SDKAgentConfig> {
+export function getAgentConfigs(
+  config?: PluginConfig,
+): Record<string, SDKAgentConfig> {
   const agents = createAgents(config);
   return Object.fromEntries(
     agents.map((a) => {
-      const sdkConfig: SDKAgentConfig = { ...a.config, description: a.description };
+      const sdkConfig: SDKAgentConfig = {
+        ...a.config,
+        description: a.description,
+      };
 
       // Apply classification-based visibility and mode
       if (isSubagent(a.name)) {
-        sdkConfig.mode = "subagent";
-      } else if (a.name === "orchestrator") {
-        sdkConfig.mode = "primary";
+        sdkConfig.mode = 'subagent';
+      } else if (a.name === 'orchestrator') {
+        sdkConfig.mode = 'primary';
       }
 
       return [a.name, sdkConfig];
-    })
+    }),
   );
 }

+ 19 - 6
src/agents/librarian.ts

@@ -1,4 +1,4 @@
-import type { AgentDefinition } from "./orchestrator";
+import type { AgentDefinition } from './orchestrator';
 
 const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebases and documentation.
 
@@ -21,14 +21,27 @@ const LIBRARIAN_PROMPT = `You are Librarian - a research specialist for codebase
 - Link to official docs when available
 - Distinguish between official and community patterns`;
 
-export function createLibrarianAgent(model: string): AgentDefinition {
+export function createLibrarianAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = LIBRARIAN_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${LIBRARIAN_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "librarian",
-    description: "External documentation and library research. Use for official docs lookup, GitHub examples, and understanding library internals.",
+    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,
+      prompt,
     },
   };
-}
+}

+ 18 - 6
src/agents/oracle.ts

@@ -1,4 +1,4 @@
-import type { AgentDefinition } from "./orchestrator";
+import type { AgentDefinition } from './orchestrator';
 
 const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor.
 
@@ -21,15 +21,27 @@ const ORACLE_PROMPT = `You are Oracle - a strategic technical advisor.
 - Focus on strategy, not execution
 - Point to specific files/lines when relevant`;
 
-export function createOracleAgent(model: string): AgentDefinition {
+export function createOracleAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = ORACLE_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${ORACLE_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "oracle",
-    description: "Strategic technical advisor. Use for architecture decisions, complex debugging, code review, and engineering guidance.",
+    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,
+      prompt,
     },
   };
 }
-

+ 19 - 6
src/agents/orchestrator.ts

@@ -1,4 +1,4 @@
-import type { AgentConfig } from "@opencode-ai/sdk";
+import type { AgentConfig } from '@opencode-ai/sdk';
 
 export interface AgentDefinition {
   name: string;
@@ -187,14 +187,27 @@ If the user's approach seems problematic:
 - Ask if they want to proceed anyway
 `;
 
-export function createOrchestratorAgent(model: string): AgentDefinition {
+export function createOrchestratorAgent(
+  model: string,
+  customPrompt?: string,
+  customAppendPrompt?: string,
+): AgentDefinition {
+  let prompt = ORCHESTRATOR_PROMPT;
+
+  if (customPrompt) {
+    prompt = customPrompt;
+  } else if (customAppendPrompt) {
+    prompt = `${ORCHESTRATOR_PROMPT}\n\n${customAppendPrompt}`;
+  }
+
   return {
-    name: "orchestrator",
-    description: "AI coding orchestrator that delegates tasks to specialist agents for optimal quality, speed, and cost",
+    name: 'orchestrator',
+    description:
+      'AI coding orchestrator that delegates tasks to specialist agents for optimal quality, speed, and cost',
     config: {
       model,
       temperature: 0.1,
-      prompt: ORCHESTRATOR_PROMPT,
+      prompt,
     },
   };
-}
+}

+ 260 - 212
src/background/background-manager.test.ts

@@ -1,277 +1,325 @@
-import { describe, expect, test, beforeEach, mock } from "bun:test"
-import { BackgroundTaskManager, type BackgroundTask, type LaunchOptions } from "./background-manager"
+import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundTaskManager } from './background-manager';
 
 // Mock the plugin context
 function createMockContext(overrides?: {
-  sessionCreateResult?: { data?: { id?: string } }
-  sessionStatusResult?: { data?: Record<string, { type: string }> }
-  sessionMessagesResult?: { data?: Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }> }
+  sessionCreateResult?: { data?: { id?: string } };
+  sessionStatusResult?: { data?: Record<string, { type: string }> };
+  sessionMessagesResult?: {
+    data?: Array<{
+      info?: { role: string };
+      parts?: Array<{ type: string; text?: string }>;
+    }>;
+  };
 }) {
   return {
     client: {
       session: {
-        create: mock(async () => overrides?.sessionCreateResult ?? { data: { id: "test-session-id" } }),
-        status: mock(async () => overrides?.sessionStatusResult ?? { data: {} }),
-        messages: mock(async () => overrides?.sessionMessagesResult ?? { data: [] }),
+        create: mock(
+          async () =>
+            overrides?.sessionCreateResult ?? {
+              data: { id: 'test-session-id' },
+            },
+        ),
+        status: mock(
+          async () => overrides?.sessionStatusResult ?? { data: {} },
+        ),
+        messages: mock(
+          async () => overrides?.sessionMessagesResult ?? { data: [] },
+        ),
         prompt: mock(async () => ({})),
       },
     },
-    directory: "/test/directory",
-  } as any
+    directory: '/test/directory',
+  } as any;
 }
 
-describe("BackgroundTaskManager", () => {
-  describe("constructor", () => {
-    test("creates manager with tmux disabled by default", () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
+describe('BackgroundTaskManager', () => {
+  describe('constructor', () => {
+    test('creates manager with tmux disabled by default', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
       // Manager should be created without errors
-      expect(manager).toBeDefined()
-    })
-
-    test("creates manager with tmux config", () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx, { enabled: true, layout: "main-vertical", main_pane_size: 60 })
-      expect(manager).toBeDefined()
-    })
-  })
-
-  describe("launch", () => {
-    test("creates new session and task", async () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
+      expect(manager).toBeDefined();
+    });
+
+    test('creates manager with tmux config', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx, {
+        enabled: true,
+        layout: 'main-vertical',
+        main_pane_size: 60,
+      });
+      expect(manager).toBeDefined();
+    });
+  });
+
+  describe('launch', () => {
+    test('creates new session and task', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
 
       const task = await manager.launch({
-        agent: "explorer",
-        prompt: "Find all test files",
-        description: "Test file search",
-        parentSessionId: "parent-123",
-      })
-
-      expect(task.id).toMatch(/^bg_/)
-      expect(task.sessionId).toBe("test-session-id")
-      expect(task.agent).toBe("explorer")
-      expect(task.description).toBe("Test file search")
-      expect(task.status).toBe("running")
-      expect(task.startedAt).toBeDefined()
-    })
-
-    test("throws when session creation fails", async () => {
-      const ctx = createMockContext({ sessionCreateResult: { data: {} } })
-      const manager = new BackgroundTaskManager(ctx)
+        agent: 'explorer',
+        prompt: 'Find all test files',
+        description: 'Test file search',
+        parentSessionId: 'parent-123',
+      });
+
+      expect(task.id).toMatch(/^bg_/);
+      expect(task.sessionId).toBe('test-session-id');
+      expect(task.agent).toBe('explorer');
+      expect(task.description).toBe('Test file search');
+      expect(task.status).toBe('running');
+      expect(task.startedAt).toBeDefined();
+    });
+
+    test('throws when session creation fails', async () => {
+      const ctx = createMockContext({ sessionCreateResult: { data: {} } });
+      const manager = new BackgroundTaskManager(ctx);
 
       await expect(
         manager.launch({
-          agent: "explorer",
-          prompt: "test",
-          description: "test",
-          parentSessionId: "parent-123",
-        })
-      ).rejects.toThrow("Failed to create background session")
-    })
-
-    test("passes model to prompt when provided", async () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
+          agent: 'explorer',
+          prompt: 'test',
+          description: 'test',
+          parentSessionId: 'parent-123',
+        }),
+      ).rejects.toThrow('Failed to create background session');
+    });
+
+    test('passes model to prompt when provided', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
 
       await manager.launch({
-        agent: "explorer",
-        prompt: "test",
-        description: "test",
-        parentSessionId: "parent-123",
-        model: "custom/model",
-      })
-
-      expect(ctx.client.session.prompt).toHaveBeenCalled()
-    })
-  })
-
-  describe("getResult", () => {
-    test("returns null for unknown task", async () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
-
-      const result = await manager.getResult("unknown-task-id")
-      expect(result).toBeNull()
-    })
-
-    test("returns task immediately when not blocking", async () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+        model: 'custom/model',
+      });
+
+      expect(ctx.client.session.prompt).toHaveBeenCalled();
+    });
+  });
+
+  describe('getResult', () => {
+    test('returns null for unknown task', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const result = await manager.getResult('unknown-task-id');
+      expect(result).toBeNull();
+    });
+
+    test('returns task immediately when not blocking', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
 
       const task = await manager.launch({
-        agent: "explorer",
-        prompt: "test",
-        description: "test",
-        parentSessionId: "parent-123",
-      })
-
-      const result = await manager.getResult(task.id, false)
-      expect(result).toBeDefined()
-      expect(result?.id).toBe(task.id)
-    })
-
-    test("returns completed task immediately even when blocking", async () => {
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      const result = await manager.getResult(task.id, false);
+      expect(result).toBeDefined();
+      expect(result?.id).toBe(task.id);
+    });
+
+    test('returns completed task immediately even when blocking', async () => {
       const ctx = createMockContext({
-        sessionStatusResult: { data: { "test-session-id": { type: "idle" } } },
+        sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
         sessionMessagesResult: {
           data: [
-            { info: { role: "assistant" }, parts: [{ type: "text", text: "Result text" }] },
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Result text' }],
+            },
           ],
         },
-      })
-      const manager = new BackgroundTaskManager(ctx)
+      });
+      const manager = new BackgroundTaskManager(ctx);
 
       const task = await manager.launch({
-        agent: "explorer",
-        prompt: "test",
-        description: "test",
-        parentSessionId: "parent-123",
-      })
-
-      const result = await manager.getResult(task.id, true)
-      expect(result?.status).toBe("completed")
-      expect(result?.result).toBe("Result text")
-    })
-  })
-
-  describe("cancel", () => {
-    test("cancels specific running task", async () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      const result = await manager.getResult(task.id, true);
+      expect(result?.status).toBe('completed');
+      expect(result?.result).toBe('Result text');
+    });
+  });
+
+  describe('cancel', () => {
+    test('cancels specific running task', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
 
       const task = await manager.launch({
-        agent: "explorer",
-        prompt: "test",
-        description: "test",
-        parentSessionId: "parent-123",
-      })
-
-      const count = manager.cancel(task.id)
-      expect(count).toBe(1)
-
-      const result = await manager.getResult(task.id)
-      expect(result?.status).toBe("failed")
-      expect(result?.error).toBe("Cancelled by user")
-    })
-
-    test("returns 0 when cancelling unknown task", () => {
-      const ctx = createMockContext()
-      const manager = new BackgroundTaskManager(ctx)
-
-      const count = manager.cancel("unknown-task-id")
-      expect(count).toBe(0)
-    })
-
-    test("cancels all running tasks when no ID provided", async () => {
-      const ctx = createMockContext()
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      const count = manager.cancel(task.id);
+      expect(count).toBe(1);
+
+      const result = await manager.getResult(task.id);
+      expect(result?.status).toBe('failed');
+      expect(result?.error).toBe('Cancelled by user');
+    });
+
+    test('returns 0 when cancelling unknown task', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const count = manager.cancel('unknown-task-id');
+      expect(count).toBe(0);
+    });
+
+    test('cancels all running tasks when no ID provided', async () => {
+      const ctx = createMockContext();
       // Make each call return a different session ID
-      let callCount = 0
+      let callCount = 0;
       ctx.client.session.create = mock(async () => {
-        callCount++
-        return { data: { id: `session-${callCount}` } }
-      })
-      const manager = new BackgroundTaskManager(ctx)
+        callCount++;
+        return { data: { id: `session-${callCount}` } };
+      });
+      const manager = new BackgroundTaskManager(ctx);
 
       await manager.launch({
-        agent: "explorer",
-        prompt: "test1",
-        description: "test1",
-        parentSessionId: "parent-123",
-      })
+        agent: 'explorer',
+        prompt: 'test1',
+        description: 'test1',
+        parentSessionId: 'parent-123',
+      });
 
       await manager.launch({
-        agent: "oracle",
-        prompt: "test2",
-        description: "test2",
-        parentSessionId: "parent-123",
-      })
+        agent: 'oracle',
+        prompt: 'test2',
+        description: 'test2',
+        parentSessionId: 'parent-123',
+      });
 
-      const count = manager.cancel()
-      expect(count).toBe(2)
-    })
+      const count = manager.cancel();
+      expect(count).toBe(2);
+    });
 
-    test("does not cancel already completed tasks", async () => {
+    test('does not cancel already completed tasks', async () => {
       const ctx = createMockContext({
-        sessionStatusResult: { data: { "test-session-id": { type: "idle" } } },
+        sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
         sessionMessagesResult: {
           data: [
-            { info: { role: "assistant" }, parts: [{ type: "text", text: "Done" }] },
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Done' }],
+            },
           ],
         },
-      })
-      const manager = new BackgroundTaskManager(ctx)
+      });
+      const manager = new BackgroundTaskManager(ctx);
 
       const task = await manager.launch({
-        agent: "explorer",
-        prompt: "test",
-        description: "test",
-        parentSessionId: "parent-123",
-      })
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
 
-      // Use getResult with block=true to wait for completion 
+      // Use getResult with block=true to wait for completion
       // This triggers polling immediately rather than relying on interval
-      const result = await manager.getResult(task.id, true, 5000)
-      expect(result?.status).toBe("completed")
+      const result = await manager.getResult(task.id, true, 5000);
+      expect(result?.status).toBe('completed');
 
       // Now try to cancel - should fail since already completed
-      const count = manager.cancel(task.id)
-      expect(count).toBe(0) // Already completed, so not cancelled
-    })
-  })
-})
-
-describe("BackgroundTask logic", () => {
-  test("extracts content from multiple types and messages", async () => {
+      const count = manager.cancel(task.id);
+      expect(count).toBe(0); // Already completed, so not cancelled
+    });
+  });
+});
+
+describe('BackgroundTask logic', () => {
+  test('extracts content from multiple types and messages', async () => {
     const ctx = createMockContext({
-      sessionStatusResult: { data: { "test-session-id": { type: "idle" } } },
+      sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
       sessionMessagesResult: {
         data: [
           {
-            info: { role: "assistant" },
+            info: { role: 'assistant' },
             parts: [
-              { type: "reasoning", text: "I am thinking..." },
-              { type: "text", text: "First part." }
-            ]
+              { type: 'reasoning', text: 'I am thinking...' },
+              { type: 'text', text: 'First part.' },
+            ],
           },
           {
-            info: { role: "assistant" },
+            info: { role: 'assistant' },
             parts: [
-              { type: "text", text: "Second part." },
-              { type: "text", text: "" } // Should be ignored
-            ]
-          }
-        ]
-      }
-    })
-    const manager = new BackgroundTaskManager(ctx)
-    const task = await manager.launch({ agent: "test", prompt: "test", description: "test", parentSessionId: "p1" })
-
-    const result = await manager.getResult(task.id, true)
-    expect(result?.status).toBe("completed")
-    expect(result?.result).toContain("I am thinking...")
-    expect(result?.result).toContain("First part.")
-    expect(result?.result).toContain("Second part.")
+              { type: 'text', text: 'Second part.' },
+              { type: 'text', text: '' }, // Should be ignored
+            ],
+          },
+        ],
+      },
+    });
+    const manager = new BackgroundTaskManager(ctx);
+    const task = await manager.launch({
+      agent: 'test',
+      prompt: 'test',
+      description: 'test',
+      parentSessionId: 'p1',
+    });
+
+    const result = await manager.getResult(task.id, true);
+    expect(result?.status).toBe('completed');
+    expect(result?.result).toContain('I am thinking...');
+    expect(result?.result).toContain('First part.');
+    expect(result?.result).toContain('Second part.');
     // Check for double newline join
-    expect(result?.result).toBe("I am thinking...\n\nFirst part.\n\nSecond part.")
-  })
+    expect(result?.result).toBe(
+      'I am thinking...\n\nFirst part.\n\nSecond part.',
+    );
+  });
 
-  test("task has completedAt timestamp on success or failure", async () => {
+  test('task has completedAt timestamp on success or failure', async () => {
     const ctx = createMockContext({
-      sessionStatusResult: { data: { "test-session-id": { type: "idle" } } },
-      sessionMessagesResult: { data: [{ info: { role: "assistant" }, parts: [{ type: "text", text: "done" }] }] }
-    })
-    const manager = new BackgroundTaskManager(ctx)
+      sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
+      sessionMessagesResult: {
+        data: [
+          {
+            info: { role: 'assistant' },
+            parts: [{ type: 'text', text: 'done' }],
+          },
+        ],
+      },
+    });
+    const manager = new BackgroundTaskManager(ctx);
 
     // Test success timestamp
-    const task1 = await manager.launch({ agent: "test", prompt: "t1", description: "d1", parentSessionId: "p1" })
-    await manager.getResult(task1.id, true)
-    expect(task1.completedAt).toBeInstanceOf(Date)
+    const task1 = await manager.launch({
+      agent: 'test',
+      prompt: 't1',
+      description: 'd1',
+      parentSessionId: 'p1',
+    });
+    await manager.getResult(task1.id, true);
+    expect(task1.completedAt).toBeInstanceOf(Date);
 
     // Test cancellation timestamp
-    const task2 = await manager.launch({ agent: "test", prompt: "t2", description: "d2", parentSessionId: "p2" })
-    manager.cancel(task2.id)
-    expect(task2.completedAt).toBeInstanceOf(Date)
-    expect(task2.status).toBe("failed")
-  })
-})
+    const task2 = await manager.launch({
+      agent: 'test',
+      prompt: 't2',
+      description: 'd2',
+      parentSessionId: 'p2',
+    });
+    manager.cancel(task2.id);
+    expect(task2.completedAt).toBeInstanceOf(Date);
+    expect(task2.status).toBe('failed');
+  });
+});

+ 91 - 55
src/background/background-manager.ts

@@ -1,10 +1,10 @@
 /**
  * Background Task Manager
- * 
+ *
  * Manages long-running AI agent tasks that execute in separate sessions.
  * Background tasks run independently from the main conversation flow, allowing
  * the user to continue working while tasks complete asynchronously.
- * 
+ *
  * Key features:
  * - Creates isolated sessions for background work
  * - Polls task status until completion
@@ -12,12 +12,13 @@
  * - Supports task cancellation and result retrieval
  */
 
-import type { PluginInput } from "@opencode-ai/plugin";
-import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from "../config";
-import type { TmuxConfig } from "../config/schema";
-import type { PluginConfig } from "../config";
-import { applyAgentVariant, resolveAgentVariant } from "../utils";
-import { log } from "../utils/logger";
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../config';
+import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from '../config';
+import type { TmuxConfig } from '../config/schema';
+import { applyAgentVariant, resolveAgentVariant } from '../utils';
+import { log } from '../utils/logger';
+
 type PromptBody = {
   messageID?: string;
   model?: { providerID: string; modelID: string };
@@ -25,37 +26,37 @@ type PromptBody = {
   noReply?: boolean;
   system?: string;
   tools?: { [key: string]: boolean };
-  parts: Array<{ type: "text"; text: string }>;
+  parts: Array<{ type: 'text'; text: string }>;
   variant?: string;
 };
 
-type OpencodeClient = PluginInput["client"];
+type OpencodeClient = PluginInput['client'];
 
 /**
  * Represents a background task running in an isolated session.
  * Tasks are tracked from creation through completion or failure.
  */
 export interface BackgroundTask {
-  id: string;           // Unique task identifier (e.g., "bg_abc123")
-  sessionId: string;    // OpenCode session ID where the task runs
-  description: string;  // Human-readable task description
-  agent: string;        // Agent name handling the task
-  status: "pending" | "running" | "completed" | "failed";
-  result?: string;      // Final output from the agent (when completed)
-  error?: string;       // Error message (when failed)
-  startedAt: Date;      // Task creation timestamp
-  completedAt?: Date;   // Task completion/failure timestamp
+  id: string; // Unique task identifier (e.g., "bg_abc123")
+  sessionId: string; // OpenCode session ID where the task runs
+  description: string; // Human-readable task description
+  agent: string; // Agent name handling the task
+  status: 'pending' | 'running' | 'completed' | 'failed';
+  result?: string; // Final output from the agent (when completed)
+  error?: string; // Error message (when failed)
+  startedAt: Date; // Task creation timestamp
+  completedAt?: Date; // Task completion/failure timestamp
 }
 
 /**
  * Options for launching a new background task.
  */
 export interface LaunchOptions {
-  agent: string;              // Agent to handle the task
-  prompt: string;             // Initial prompt to send to the agent
-  description: string;        // Human-readable task description
-  parentSessionId: string;    // Parent session ID for task hierarchy
-  model?: string;             // Optional model override
+  agent: string; // Agent to handle the task
+  prompt: string; // Initial prompt to send to the agent
+  description: string; // Human-readable task description
+  parentSessionId: string; // Parent session ID for task hierarchy
+  model?: string; // Optional model override
 }
 
 function generateTaskId(): string {
@@ -70,7 +71,11 @@ export class BackgroundTaskManager {
   private tmuxEnabled: boolean;
   private config?: PluginConfig;
 
-  constructor(ctx: PluginInput, tmuxConfig?: TmuxConfig, config?: PluginConfig) {
+  constructor(
+    ctx: PluginInput,
+    tmuxConfig?: TmuxConfig,
+    config?: PluginConfig,
+  ) {
     this.client = ctx.client;
     this.directory = ctx.directory;
     this.tmuxEnabled = tmuxConfig?.enabled ?? false;
@@ -79,10 +84,10 @@ export class BackgroundTaskManager {
 
   /**
    * Launch a new background task in an isolated session.
-   * 
+   *
    * Creates a new session, registers the task, starts polling for completion,
    * and sends the initial prompt to the specified agent.
-   * 
+   *
    * @param opts - Task configuration options
    * @returns The created background task object
    * @throws Error if session creation fails
@@ -97,7 +102,7 @@ export class BackgroundTaskManager {
     });
 
     if (!session.data?.id) {
-      throw new Error("Failed to create background session");
+      throw new Error('Failed to create background session');
     }
 
     const task: BackgroundTask = {
@@ -105,7 +110,7 @@ export class BackgroundTaskManager {
       sessionId: session.data.id,
       description: opts.description,
       agent: opts.agent,
-      status: "running",
+      status: 'running',
       startedAt: new Date(),
     };
 
@@ -125,15 +130,16 @@ export class BackgroundTaskManager {
       promptQuery.model = opts.model;
     }
 
-    log(`[background-manager] launching task for agent="${opts.agent}"`, { description: opts.description });
+    log(`[background-manager] launching task for agent="${opts.agent}"`, {
+      description: opts.description,
+    });
     const resolvedVariant = resolveAgentVariant(this.config, opts.agent);
     const promptBody = applyAgentVariant(resolvedVariant, {
       agent: opts.agent,
       tools: { background_task: false, task: false },
-      parts: [{ type: "text" as const, text: opts.prompt }],
+      parts: [{ type: 'text' as const, text: opts.prompt }],
     } as PromptBody) as unknown as PromptBody;
 
-
     await this.client.session.prompt({
       path: { id: session.data.id },
       body: promptBody,
@@ -145,24 +151,31 @@ export class BackgroundTaskManager {
 
   /**
    * Retrieve the current state of a background task.
-   * 
+   *
    * @param taskId - The task ID to retrieve
    * @param block - If true, wait for task completion before returning
    * @param timeout - Maximum time to wait in milliseconds (default: 2 minutes)
    * @returns The task object, or null if not found
    */
-  async getResult(taskId: string, block = false, timeout = 120000): Promise<BackgroundTask | null> {
+  async getResult(
+    taskId: string,
+    block = false,
+    timeout = 120000,
+  ): Promise<BackgroundTask | null> {
     const task = this.tasks.get(taskId);
     if (!task) return null;
 
-    if (!block || task.status === "completed" || task.status === "failed") {
+    if (!block || task.status === 'completed' || task.status === 'failed') {
       return task;
     }
 
     const deadline = Date.now() + timeout;
     while (Date.now() < deadline) {
       await this.pollTask(task);
-      if ((task.status as string) === "completed" || (task.status as string) === "failed") {
+      if (
+        (task.status as string) === 'completed' ||
+        (task.status as string) === 'failed'
+      ) {
         return task;
       }
       await new Promise((r) => setTimeout(r, POLL_INTERVAL_SLOW_MS));
@@ -173,16 +186,16 @@ export class BackgroundTaskManager {
 
   /**
    * Cancel one or all running background tasks.
-   * 
+   *
    * @param taskId - Optional task ID to cancel. If omitted, cancels all running tasks.
    * @returns Number of tasks cancelled
    */
   cancel(taskId?: string): number {
     if (taskId) {
       const task = this.tasks.get(taskId);
-      if (task && task.status === "running") {
-        task.status = "failed";
-        task.error = "Cancelled by user";
+      if (task && task.status === 'running') {
+        task.status = 'failed';
+        task.error = 'Cancelled by user';
         task.completedAt = new Date();
         return 1;
       }
@@ -191,9 +204,9 @@ export class BackgroundTaskManager {
 
     let count = 0;
     for (const task of this.tasks.values()) {
-      if (task.status === "running") {
-        task.status = "failed";
-        task.error = "Cancelled by user";
+      if (task.status === 'running') {
+        task.status = 'failed';
+        task.error = 'Cancelled by user';
         task.completedAt = new Date();
         count++;
       }
@@ -207,7 +220,10 @@ export class BackgroundTaskManager {
    */
   private startPolling() {
     if (this.pollInterval) return;
-    this.pollInterval = setInterval(() => this.pollAllTasks(), POLL_INTERVAL_BACKGROUND_MS);
+    this.pollInterval = setInterval(
+      () => this.pollAllTasks(),
+      POLL_INTERVAL_BACKGROUND_MS,
+    );
   }
 
   /**
@@ -215,7 +231,9 @@ export class BackgroundTaskManager {
    * Stops polling automatically when no tasks are running.
    */
   private async pollAllTasks() {
-    const runningTasks = [...this.tasks.values()].filter((t) => t.status === "running");
+    const runningTasks = [...this.tasks.values()].filter(
+      (t) => t.status === 'running',
+    );
     if (runningTasks.length === 0 && this.pollInterval) {
       clearInterval(this.pollInterval);
       this.pollInterval = undefined;
@@ -229,7 +247,7 @@ export class BackgroundTaskManager {
 
   /**
    * Poll a single task for completion.
-   * 
+   *
    * Checks if the session is idle, then retrieves assistant messages.
    * Updates task status to completed/failed based on the response.
    */
@@ -237,18 +255,31 @@ export class BackgroundTaskManager {
     try {
       // Check session status first
       const statusResult = await this.client.session.status();
-      const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+      const allStatuses = (statusResult.data ?? {}) as Record<
+        string,
+        { type: string }
+      >;
       const sessionStatus = allStatuses[task.sessionId];
 
       // If session is still active (not idle), don't try to read messages yet
-      if (task.status !== "running" || (sessionStatus && sessionStatus.type !== "idle")) {
+      if (
+        task.status !== 'running' ||
+        (sessionStatus && sessionStatus.type !== 'idle')
+      ) {
         return;
       }
 
       // Get messages using correct API
-      const messagesResult = await this.client.session.messages({ path: { id: task.sessionId } });
-      const messages = (messagesResult.data ?? []) as Array<{ info?: { role: string }; parts?: Array<{ type: string; text?: string }> }>;
-      const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
+      const messagesResult = await this.client.session.messages({
+        path: { id: task.sessionId },
+      });
+      const messages = (messagesResult.data ?? []) as Array<{
+        info?: { role: string };
+        parts?: Array<{ type: string; text?: string }>;
+      }>;
+      const assistantMessages = messages.filter(
+        (m) => m.info?.role === 'assistant',
+      );
 
       if (assistantMessages.length === 0) {
         return; // No response yet
@@ -258,21 +289,26 @@ export class BackgroundTaskManager {
       const extractedContent: string[] = [];
       for (const message of assistantMessages) {
         for (const part of message.parts ?? []) {
-          if ((part.type === "text" || part.type === "reasoning") && part.text) {
+          if (
+            (part.type === 'text' || part.type === 'reasoning') &&
+            part.text
+          ) {
             extractedContent.push(part.text);
           }
         }
       }
 
-      const responseText = extractedContent.filter((t) => t.length > 0).join("\n\n");
+      const responseText = extractedContent
+        .filter((t) => t.length > 0)
+        .join('\n\n');
       if (responseText) {
         task.result = responseText;
-        task.status = "completed";
+        task.status = 'completed';
         task.completedAt = new Date();
         // Pane closing is handled by TmuxSessionManager via polling
       }
     } catch (error) {
-      task.status = "failed";
+      task.status = 'failed';
       task.error = error instanceof Error ? error.message : String(error);
       task.completedAt = new Date();
       // Pane closing is handled by TmuxSessionManager via polling

+ 6 - 2
src/background/index.ts

@@ -1,2 +1,6 @@
-export { BackgroundTaskManager, type BackgroundTask, type LaunchOptions } from "./background-manager";
-export { TmuxSessionManager } from "./tmux-session-manager";
+export {
+  type BackgroundTask,
+  BackgroundTaskManager,
+  type LaunchOptions,
+} from './background-manager';
+export { TmuxSessionManager } from './tmux-session-manager';

+ 141 - 133
src/background/tmux-session-manager.test.ts

@@ -1,167 +1,175 @@
-import { describe, expect, test, mock, beforeEach } from "bun:test";
-import { TmuxSessionManager } from "./tmux-session-manager";
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { TmuxSessionManager } from './tmux-session-manager';
 
 // Define the mock outside so we can access it
-const mockSpawnTmuxPane = mock(async () => ({ success: true, paneId: "%mock-pane" }));
+const mockSpawnTmuxPane = mock(async () => ({
+  success: true,
+  paneId: '%mock-pane',
+}));
 const mockCloseTmuxPane = mock(async () => true);
 const mockIsInsideTmux = mock(() => true);
 
 // Mock the tmux utils module
-mock.module("../utils/tmux", () => ({
-    spawnTmuxPane: mockSpawnTmuxPane,
-    closeTmuxPane: mockCloseTmuxPane,
-    isInsideTmux: mockIsInsideTmux,
+mock.module('../utils/tmux', () => ({
+  spawnTmuxPane: mockSpawnTmuxPane,
+  closeTmuxPane: mockCloseTmuxPane,
+  isInsideTmux: mockIsInsideTmux,
 }));
 
 // Mock the plugin context
 function createMockContext(overrides?: {
-    sessionStatusResult?: { data?: Record<string, { type: string }> }
+  sessionStatusResult?: { data?: Record<string, { type: string }> };
 }) {
-    const defaultPort = process.env.OPENCODE_PORT ?? "4096";
-    return {
-        client: {
-            session: {
-                status: mock(async () => overrides?.sessionStatusResult ?? { data: {} }),
-            },
-        },
-        serverUrl: new URL(`http://localhost:${defaultPort}`),
-    } as any;
+  const defaultPort = process.env.OPENCODE_PORT ?? '4096';
+  return {
+    client: {
+      session: {
+        status: mock(
+          async () => overrides?.sessionStatusResult ?? { data: {} },
+        ),
+      },
+    },
+    serverUrl: new URL(`http://localhost:${defaultPort}`),
+  } as any;
 }
 
 const defaultTmuxConfig = {
-    enabled: true,
-    layout: "main-vertical" as const,
-    main_pane_size: 60,
+  enabled: true,
+  layout: 'main-vertical' as const,
+  main_pane_size: 60,
 };
 
-describe("TmuxSessionManager", () => {
-    beforeEach(() => {
-        mockSpawnTmuxPane.mockClear();
-        mockCloseTmuxPane.mockClear();
-        mockIsInsideTmux.mockClear();
-        mockIsInsideTmux.mockReturnValue(true);
+describe('TmuxSessionManager', () => {
+  beforeEach(() => {
+    mockSpawnTmuxPane.mockClear();
+    mockCloseTmuxPane.mockClear();
+    mockIsInsideTmux.mockClear();
+    mockIsInsideTmux.mockReturnValue(true);
+  });
+
+  describe('constructor', () => {
+    test('initializes with config', () => {
+      const ctx = createMockContext();
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      expect(manager).toBeDefined();
     });
+  });
+
+  describe('onSessionCreated', () => {
+    test('spawns pane for child sessions', async () => {
+      const ctx = createMockContext();
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'child-123',
+            parentID: 'parent-456',
+            title: 'Test Worker',
+          },
+        },
+      });
 
-    describe("constructor", () => {
-        test("initializes with config", () => {
-            const ctx = createMockContext();
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
-            expect(manager).toBeDefined();
-        });
+      expect(mockSpawnTmuxPane).toHaveBeenCalled();
     });
 
-    describe("onSessionCreated", () => {
-        test("spawns pane for child sessions", async () => {
-            const ctx = createMockContext();
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
-
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: {
-                    info: {
-                        id: "child-123",
-                        parentID: "parent-456",
-                        title: "Test Worker",
-                    },
-                },
-            });
-
-            expect(mockSpawnTmuxPane).toHaveBeenCalled();
-        });
-
-        test("ignores sessions without parentID", async () => {
-            const ctx = createMockContext();
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
-
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: {
-                    info: {
-                        id: "root-session",
-                        title: "Main Chat",
-                    },
-                },
-            });
-
-            expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
-        });
-
-        test("ignores if disabled in config", async () => {
-            const ctx = createMockContext();
-            const manager = new TmuxSessionManager(ctx, { ...defaultTmuxConfig, enabled: false });
-
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: {
-                    info: { id: "child", parentID: "parent" },
-                },
-            });
-
-            expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
-        });
+    test('ignores sessions without parentID', async () => {
+      const ctx = createMockContext();
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'root-session',
+            title: 'Main Chat',
+          },
+        },
+      });
+
+      expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
     });
 
-    describe("polling and closure", () => {
-        test("closes pane when session becomes idle", async () => {
-            const ctx = createMockContext();
-            mockSpawnTmuxPane.mockResolvedValue({ success: true, paneId: "p-1" });
+    test('ignores if disabled in config', async () => {
+      const ctx = createMockContext();
+      const manager = new TmuxSessionManager(ctx, {
+        ...defaultTmuxConfig,
+        enabled: false,
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: { id: 'child', parentID: 'parent' },
+        },
+      });
 
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      expect(mockSpawnTmuxPane).not.toHaveBeenCalled();
+    });
+  });
 
-            // Register session
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: { info: { id: "c1", parentID: "p1" } },
-            });
+  describe('polling and closure', () => {
+    test('closes pane when session becomes idle', async () => {
+      const ctx = createMockContext();
+      mockSpawnTmuxPane.mockResolvedValue({ success: true, paneId: 'p-1' });
 
-            // Mock status
-            ctx.client.session.status.mockResolvedValue({
-                data: { "c1": { type: "idle" } },
-            });
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
 
-            await (manager as any).pollSessions();
+      // Register session
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      });
 
-            expect(mockCloseTmuxPane).toHaveBeenCalledWith("p-1");
-        });
+      // Mock status
+      ctx.client.session.status.mockResolvedValue({
+        data: { c1: { type: 'idle' } },
+      });
 
-        test("does not close on transient status absence", async () => {
-            const ctx = createMockContext();
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+      await (manager as any).pollSessions();
 
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: { info: { id: "c1", parentID: "p1" } },
-            });
+      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p-1');
+    });
 
-            ctx.client.session.status.mockResolvedValue({ data: {} });
-            await (manager as any).pollSessions();
+    test('does not close on transient status absence', async () => {
+      const ctx = createMockContext();
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
 
-            expect(mockCloseTmuxPane).not.toHaveBeenCalled();
-        });
-    });
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      });
 
-    describe("cleanup", () => {
-        test("closes all tracked panes concurrently", async () => {
-            const ctx = createMockContext();
-            mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: "p1" });
-            mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: "p2" });
-
-            const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
-
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: { info: { id: "s1", parentID: "p1" } },
-            });
-            await manager.onSessionCreated({
-                type: "session.created",
-                properties: { info: { id: "s2", parentID: "p2" } },
-            });
-
-            await manager.cleanup();
-
-            expect(mockCloseTmuxPane).toHaveBeenCalledTimes(2);
-            expect(mockCloseTmuxPane).toHaveBeenCalledWith("p1");
-            expect(mockCloseTmuxPane).toHaveBeenCalledWith("p2");
-        });
+      ctx.client.session.status.mockResolvedValue({ data: {} });
+      await (manager as any).pollSessions();
+
+      expect(mockCloseTmuxPane).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('cleanup', () => {
+    test('closes all tracked panes concurrently', async () => {
+      const ctx = createMockContext();
+      mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: 'p1' });
+      mockSpawnTmuxPane.mockResolvedValueOnce({ success: true, paneId: 'p2' });
+
+      const manager = new TmuxSessionManager(ctx, defaultTmuxConfig);
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 's1', parentID: 'p1' } },
+      });
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: { info: { id: 's2', parentID: 'p2' } },
+      });
+
+      await manager.cleanup();
+
+      expect(mockCloseTmuxPane).toHaveBeenCalledTimes(2);
+      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p1');
+      expect(mockCloseTmuxPane).toHaveBeenCalledWith('p2');
     });
+  });
 });

+ 49 - 32
src/background/tmux-session-manager.ts

@@ -1,10 +1,10 @@
-import type { PluginInput } from "@opencode-ai/plugin";
-import { spawnTmuxPane, closeTmuxPane, isInsideTmux } from "../utils/tmux";
-import type { TmuxConfig } from "../config/schema";
-import { log } from "../utils/logger";
-import { POLL_INTERVAL_BACKGROUND_MS } from "../config";
+import type { PluginInput } from '@opencode-ai/plugin';
+import { POLL_INTERVAL_BACKGROUND_MS } from '../config';
+import type { TmuxConfig } from '../config/schema';
+import { log } from '../utils/logger';
+import { closeTmuxPane, isInsideTmux, spawnTmuxPane } from '../utils/tmux';
 
-type OpencodeClient = PluginInput["client"];
+type OpencodeClient = PluginInput['client'];
 
 interface TrackedSession {
   sessionId: string;
@@ -42,11 +42,12 @@ export class TmuxSessionManager {
   constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
     this.client = ctx.client;
     this.tmuxConfig = tmuxConfig;
-    const defaultPort = process.env.OPENCODE_PORT ?? "4096";
-    this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
+    const defaultPort = process.env.OPENCODE_PORT ?? '4096';
+    this.serverUrl =
+      ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`;
     this.enabled = tmuxConfig.enabled && isInsideTmux();
 
-    log("[tmux-session-manager] initialized", {
+    log('[tmux-session-manager] initialized', {
       enabled: this.enabled,
       tmuxConfig: this.tmuxConfig,
       serverUrl: this.serverUrl,
@@ -62,7 +63,7 @@ export class TmuxSessionManager {
     properties?: { info?: { id?: string; parentID?: string; title?: string } };
   }): Promise<void> {
     if (!this.enabled) return;
-    if (event.type !== "session.created") return;
+    if (event.type !== 'session.created') return;
 
     const info = event.properties?.info;
     if (!info?.id || !info?.parentID) {
@@ -72,15 +73,15 @@ export class TmuxSessionManager {
 
     const sessionId = info.id;
     const parentId = info.parentID;
-    const title = info.title ?? "Subagent";
+    const title = info.title ?? 'Subagent';
 
     // Skip if we're already tracking this session
     if (this.sessions.has(sessionId)) {
-      log("[tmux-session-manager] session already tracked", { sessionId });
+      log('[tmux-session-manager] session already tracked', { sessionId });
       return;
     }
 
-    log("[tmux-session-manager] child session created, spawning pane", {
+    log('[tmux-session-manager] child session created, spawning pane', {
       sessionId,
       parentId,
       title,
@@ -90,9 +91,11 @@ export class TmuxSessionManager {
       sessionId,
       title,
       this.tmuxConfig,
-      this.serverUrl
+      this.serverUrl,
     ).catch((err) => {
-      log("[tmux-session-manager] failed to spawn pane", { error: String(err) });
+      log('[tmux-session-manager] failed to spawn pane', {
+        error: String(err),
+      });
       return { success: false, paneId: undefined };
     });
 
@@ -107,7 +110,7 @@ export class TmuxSessionManager {
         lastSeenAt: now,
       });
 
-      log("[tmux-session-manager] pane spawned", {
+      log('[tmux-session-manager] pane spawned', {
         sessionId,
         paneId: paneResult.paneId,
       });
@@ -119,15 +122,18 @@ export class TmuxSessionManager {
   private startPolling(): void {
     if (this.pollInterval) return;
 
-    this.pollInterval = setInterval(() => this.pollSessions(), POLL_INTERVAL_BACKGROUND_MS);
-    log("[tmux-session-manager] polling started");
+    this.pollInterval = setInterval(
+      () => this.pollSessions(),
+      POLL_INTERVAL_BACKGROUND_MS,
+    );
+    log('[tmux-session-manager] polling started');
   }
 
   private stopPolling(): void {
     if (this.pollInterval) {
       clearInterval(this.pollInterval);
       this.pollInterval = undefined;
-      log("[tmux-session-manager] polling stopped");
+      log('[tmux-session-manager] polling stopped');
     }
   }
 
@@ -139,7 +145,10 @@ export class TmuxSessionManager {
 
     try {
       const statusResult = await this.client.session.status();
-      const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>;
+      const allStatuses = (statusResult.data ?? {}) as Record<
+        string,
+        { type: string }
+      >;
 
       const now = Date.now();
       const sessionsToClose: string[] = [];
@@ -148,7 +157,7 @@ export class TmuxSessionManager {
         const status = allStatuses[sessionId];
 
         // Session is idle (completed).
-        const isIdle = status?.type === "idle";
+        const isIdle = status?.type === 'idle';
 
         if (status) {
           tracked.lastSeenAt = now;
@@ -157,8 +166,9 @@ export class TmuxSessionManager {
           tracked.missingSince = now;
         }
 
-        const missingTooLong = !!tracked.missingSince
-          && now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
+        const missingTooLong =
+          !!tracked.missingSince &&
+          now - tracked.missingSince >= SESSION_MISSING_GRACE_MS;
 
         // Check for timeout as a safety fallback
         const isTimedOut = now - tracked.createdAt > SESSION_TIMEOUT_MS;
@@ -172,7 +182,7 @@ export class TmuxSessionManager {
         await this.closeSession(sessionId);
       }
     } catch (err) {
-      log("[tmux-session-manager] poll error", { error: String(err) });
+      log('[tmux-session-manager] poll error', { error: String(err) });
     }
   }
 
@@ -180,7 +190,7 @@ export class TmuxSessionManager {
     const tracked = this.sessions.get(sessionId);
     if (!tracked) return;
 
-    log("[tmux-session-manager] closing session pane", {
+    log('[tmux-session-manager] closing session pane', {
       sessionId,
       paneId: tracked.paneId,
     });
@@ -196,7 +206,9 @@ export class TmuxSessionManager {
   /**
    * Create the event handler for the plugin's event hook.
    */
-  createEventHandler(): (input: { event: { type: string; properties?: unknown } }) => Promise<void> {
+  createEventHandler(): (input: {
+    event: { type: string; properties?: unknown };
+  }) => Promise<void> {
     return async (input) => {
       await this.onSessionCreated(input.event as SessionCreatedEvent);
     };
@@ -209,16 +221,21 @@ export class TmuxSessionManager {
     this.stopPolling();
 
     if (this.sessions.size > 0) {
-      log("[tmux-session-manager] closing all panes", { count: this.sessions.size });
-      const closePromises = Array.from(this.sessions.values()).map(s =>
-        closeTmuxPane(s.paneId).catch(err =>
-          log("[tmux-session-manager] cleanup error for pane", { paneId: s.paneId, error: String(err) })
-        )
+      log('[tmux-session-manager] closing all panes', {
+        count: this.sessions.size,
+      });
+      const closePromises = Array.from(this.sessions.values()).map((s) =>
+        closeTmuxPane(s.paneId).catch((err) =>
+          log('[tmux-session-manager] cleanup error for pane', {
+            paneId: s.paneId,
+            error: String(err),
+          }),
+        ),
       );
       await Promise.all(closePromises);
       this.sessions.clear();
     }
 
-    log("[tmux-session-manager] cleanup complete");
+    log('[tmux-session-manager] cleanup complete');
   }
 }

+ 203 - 170
src/cli/config-io.test.ts

@@ -1,190 +1,223 @@
 /// <reference types="bun-types" />
 
-import { describe, expect, test, afterEach, beforeEach, mock } from "bun:test"
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import {
+  existsSync,
+  mkdtempSync,
+  readFileSync,
+  rmSync,
+  writeFileSync,
+} from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
 import {
-  stripJsonComments,
-  parseConfigFile,
-  parseConfig,
-  writeConfig,
-  addPluginToOpenCodeConfig,
   addAuthPlugins,
+  addPluginToOpenCodeConfig,
   addProviderConfig,
-  writeLiteConfig,
-  disableDefaultAgents,
   detectCurrentConfig,
-} from "./config-io"
-import { join } from "node:path"
-import { existsSync, rmSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"
-import { tmpdir } from "node:os"
-import * as paths from "./paths"
-import * as system from "./system"
+  disableDefaultAgents,
+  parseConfig,
+  parseConfigFile,
+  stripJsonComments,
+  writeConfig,
+  writeLiteConfig,
+} from './config-io';
+import * as paths from './paths';
 
-describe("config-io", () => {
-  let tmpDir: string
-  const originalEnv = { ...process.env }
+describe('config-io', () => {
+  let tmpDir: string;
+  const originalEnv = { ...process.env };
 
   beforeEach(() => {
-    tmpDir = mkdtempSync(join(tmpdir(), "opencode-io-test-"))
-    process.env.XDG_CONFIG_HOME = tmpDir
-  })
+    tmpDir = mkdtempSync(join(tmpdir(), 'opencode-io-test-'));
+    process.env.XDG_CONFIG_HOME = tmpDir;
+  });
 
   afterEach(() => {
-    process.env = { ...originalEnv }
+    process.env = { ...originalEnv };
     if (tmpDir && existsSync(tmpDir)) {
-      rmSync(tmpDir, { recursive: true, force: true })
+      rmSync(tmpDir, { recursive: true, force: true });
     }
-    mock.restore()
-  })
+    mock.restore();
+  });
 
-  test("stripJsonComments strips comments and trailing commas", () => {
+  test('stripJsonComments strips comments and trailing commas', () => {
     const jsonc = `{
       // comment
       "a": 1, /* multi
       line */
       "b": [2,],
-    }`
-    const stripped = stripJsonComments(jsonc)
-    expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] })
-  })
-
-  test("parseConfigFile parses valid JSON", () => {
-    const path = join(tmpDir, "test.json")
-    writeFileSync(path, '{"a": 1}')
-    const result = parseConfigFile(path)
-    expect(result.config).toEqual({ a: 1 } as any)
-    expect(result.error).toBeUndefined()
-  })
-
-  test("parseConfigFile returns null for non-existent file", () => {
-    const result = parseConfigFile(join(tmpDir, "nonexistent.json"))
-    expect(result.config).toBeNull()
-  })
-
-  test("parseConfigFile returns null for empty or whitespace-only file", () => {
-    const emptyPath = join(tmpDir, "empty.json")
-    writeFileSync(emptyPath, "")
-    expect(parseConfigFile(emptyPath).config).toBeNull()
-    
-    const whitespacePath = join(tmpDir, "whitespace.json")
-    writeFileSync(whitespacePath, "   \n  ")
-    expect(parseConfigFile(whitespacePath).config).toBeNull()
-  })
-
-  test("parseConfigFile returns error for invalid JSON", () => {
-    const path = join(tmpDir, "invalid.json")
-    writeFileSync(path, '{"a": 1')
-    const result = parseConfigFile(path)
-    expect(result.config).toBeNull()
-    expect(result.error).toBeDefined()
-  })
-
-  test("parseConfig tries .jsonc if .json is missing", () => {
-    const jsoncPath = join(tmpDir, "test.jsonc")
-    writeFileSync(jsoncPath, '{"a": 1}')
-    
+    }`;
+    const stripped = stripJsonComments(jsonc);
+    expect(JSON.parse(stripped)).toEqual({ a: 1, b: [2] });
+  });
+
+  test('parseConfigFile parses valid JSON', () => {
+    const path = join(tmpDir, 'test.json');
+    writeFileSync(path, '{"a": 1}');
+    const result = parseConfigFile(path);
+    expect(result.config).toEqual({ a: 1 } as any);
+    expect(result.error).toBeUndefined();
+  });
+
+  test('parseConfigFile returns null for non-existent file', () => {
+    const result = parseConfigFile(join(tmpDir, 'nonexistent.json'));
+    expect(result.config).toBeNull();
+  });
+
+  test('parseConfigFile returns null for empty or whitespace-only file', () => {
+    const emptyPath = join(tmpDir, 'empty.json');
+    writeFileSync(emptyPath, '');
+    expect(parseConfigFile(emptyPath).config).toBeNull();
+
+    const whitespacePath = join(tmpDir, 'whitespace.json');
+    writeFileSync(whitespacePath, '   \n  ');
+    expect(parseConfigFile(whitespacePath).config).toBeNull();
+  });
+
+  test('parseConfigFile returns error for invalid JSON', () => {
+    const path = join(tmpDir, 'invalid.json');
+    writeFileSync(path, '{"a": 1');
+    const result = parseConfigFile(path);
+    expect(result.config).toBeNull();
+    expect(result.error).toBeDefined();
+  });
+
+  test('parseConfig tries .jsonc if .json is missing', () => {
+    const jsoncPath = join(tmpDir, 'test.jsonc');
+    writeFileSync(jsoncPath, '{"a": 1}');
+
     // We pass .json path, it should try .jsonc
-    const result = parseConfig(join(tmpDir, "test.json"))
-    expect(result.config).toEqual({ a: 1 } as any)
-  })
-
-  test("writeConfig writes JSON and creates backup", () => {
-    const path = join(tmpDir, "test.json")
-    writeFileSync(path, '{"old": true}')
-    
-    writeConfig(path, { new: true } as any)
-    
-    expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual({ new: true })
-    expect(JSON.parse(readFileSync(path + ".bak", "utf-8"))).toEqual({ old: true })
-  })
-
-  test("addPluginToOpenCodeConfig adds plugin and removes duplicates", async () => {
-    const configPath = join(tmpDir, "opencode", "opencode.json")
-    paths.ensureConfigDir()
-    writeFileSync(configPath, JSON.stringify({ plugin: ["other", "oh-my-opencode-slim@1.0.0"] }))
-    
-    const result = await addPluginToOpenCodeConfig()
-    expect(result.success).toBe(true)
-    
-    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
-    expect(saved.plugin).toContain("oh-my-opencode-slim")
-    expect(saved.plugin).not.toContain("oh-my-opencode-slim@1.0.0")
-    expect(saved.plugin.length).toBe(2)
-  })
-
-  test("addAuthPlugins adds antigravity auth plugin", async () => {
-    const configPath = join(tmpDir, "opencode", "opencode.json")
-    paths.ensureConfigDir()
-    writeFileSync(configPath, JSON.stringify({}))
-    
-    mock.module("./system", () => ({
-      fetchLatestVersion: async () => "1.2.3"
-    }))
-
-    const result = await addAuthPlugins({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: false })
-    expect(result.success).toBe(true)
-    
-    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
-    expect(saved.plugin).toContain("opencode-antigravity-auth@1.2.3")
-  })
-
-  test("addProviderConfig adds google provider config", () => {
-    const configPath = join(tmpDir, "opencode", "opencode.json")
-    paths.ensureConfigDir()
-    writeFileSync(configPath, JSON.stringify({}))
-    
-    const result = addProviderConfig({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: false })
-    expect(result.success).toBe(true)
-    
-    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
-    expect(saved.provider.google).toBeDefined()
-  })
-
-  test("writeLiteConfig writes lite config", () => {
-    const litePath = join(tmpDir, "opencode", "oh-my-opencode-slim.json")
-    paths.ensureConfigDir()
-    
-    const result = writeLiteConfig({ hasAntigravity: true, hasOpenAI: false, hasOpencodeZen: false, hasTmux: true })
-    expect(result.success).toBe(true)
-    
-    const saved = JSON.parse(readFileSync(litePath, "utf-8"))
-    expect(saved.preset).toBe("antigravity")
-    expect(saved.presets.antigravity).toBeDefined()
-    expect(saved.tmux.enabled).toBe(true)
-  })
-
-  test("disableDefaultAgents disables explore and general agents", () => {
-    const configPath = join(tmpDir, "opencode", "opencode.json")
-    paths.ensureConfigDir()
-    writeFileSync(configPath, JSON.stringify({}))
-    
-    const result = disableDefaultAgents()
-    expect(result.success).toBe(true)
-    
-    const saved = JSON.parse(readFileSync(configPath, "utf-8"))
-    expect(saved.agent.explore.disable).toBe(true)
-    expect(saved.agent.general.disable).toBe(true)
-  })
-
-  test("detectCurrentConfig detects installed status", () => {
-    const configPath = join(tmpDir, "opencode", "opencode.json")
-    const litePath = join(tmpDir, "opencode", "oh-my-opencode-slim.json")
-    paths.ensureConfigDir()
-    
-    writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode-slim", "opencode-antigravity-auth"] }))
-    writeFileSync(litePath, JSON.stringify({ 
-      preset: "openai",
-      presets: {
-        openai: {
-          orchestrator: { model: "openai/gpt-4" }
-        }
-      },
-      tmux: { enabled: true }
-    }))
-    
-    const detected = detectCurrentConfig()
-    expect(detected.isInstalled).toBe(true)
-    expect(detected.hasAntigravity).toBe(true)
-    expect(detected.hasOpenAI).toBe(true)
-    expect(detected.hasTmux).toBe(true)
-  })
-})
+    const result = parseConfig(join(tmpDir, 'test.json'));
+    expect(result.config).toEqual({ a: 1 } as any);
+  });
+
+  test('writeConfig writes JSON and creates backup', () => {
+    const path = join(tmpDir, 'test.json');
+    writeFileSync(path, '{"old": true}');
+
+    writeConfig(path, { new: true } as any);
+
+    expect(JSON.parse(readFileSync(path, 'utf-8'))).toEqual({ new: true });
+    expect(JSON.parse(readFileSync(`${path}.bak`, 'utf-8'))).toEqual({
+      old: true,
+    });
+  });
+
+  test('addPluginToOpenCodeConfig adds plugin and removes duplicates', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+    writeFileSync(
+      configPath,
+      JSON.stringify({ plugin: ['other', 'oh-my-opencode-slim@1.0.0'] }),
+    );
+
+    const result = await addPluginToOpenCodeConfig();
+    expect(result.success).toBe(true);
+
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toContain('oh-my-opencode-slim');
+    expect(saved.plugin).not.toContain('oh-my-opencode-slim@1.0.0');
+    expect(saved.plugin.length).toBe(2);
+  });
+
+  test('addAuthPlugins adds antigravity auth plugin', async () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({}));
+
+    mock.module('./system', () => ({
+      fetchLatestVersion: async () => '1.2.3',
+    }));
+
+    const result = await addAuthPlugins({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: false,
+    });
+    expect(result.success).toBe(true);
+
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.plugin).toContain('opencode-antigravity-auth@1.2.3');
+  });
+
+  test('addProviderConfig adds google provider config', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({}));
+
+    const result = addProviderConfig({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: false,
+    });
+    expect(result.success).toBe(true);
+
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.provider.google).toBeDefined();
+  });
+
+  test('writeLiteConfig writes lite config', () => {
+    const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
+    paths.ensureConfigDir();
+
+    const result = writeLiteConfig({
+      hasAntigravity: true,
+      hasOpenAI: false,
+      hasOpencodeZen: false,
+      hasTmux: true,
+    });
+    expect(result.success).toBe(true);
+
+    const saved = JSON.parse(readFileSync(litePath, 'utf-8'));
+    expect(saved.preset).toBe('antigravity');
+    expect(saved.presets.antigravity).toBeDefined();
+    expect(saved.tmux.enabled).toBe(true);
+  });
+
+  test('disableDefaultAgents disables explore and general agents', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    paths.ensureConfigDir();
+    writeFileSync(configPath, JSON.stringify({}));
+
+    const result = disableDefaultAgents();
+    expect(result.success).toBe(true);
+
+    const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
+    expect(saved.agent.explore.disable).toBe(true);
+    expect(saved.agent.general.disable).toBe(true);
+  });
+
+  test('detectCurrentConfig detects installed status', () => {
+    const configPath = join(tmpDir, 'opencode', 'opencode.json');
+    const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
+    paths.ensureConfigDir();
+
+    writeFileSync(
+      configPath,
+      JSON.stringify({
+        plugin: ['oh-my-opencode-slim', 'opencode-antigravity-auth'],
+      }),
+    );
+    writeFileSync(
+      litePath,
+      JSON.stringify({
+        preset: 'openai',
+        presets: {
+          openai: {
+            orchestrator: { model: 'openai/gpt-4' },
+          },
+        },
+        tmux: { enabled: true },
+      }),
+    );
+
+    const detected = detectCurrentConfig();
+    expect(detected.isInstalled).toBe(true);
+    expect(detected.hasAntigravity).toBe(true);
+    expect(detected.hasOpenAI).toBe(true);
+    expect(detected.hasTmux).toBe(true);
+  });
+});

+ 168 - 125
src/cli/config-io.ts

@@ -1,239 +1,278 @@
-import { existsSync, readFileSync, writeFileSync, statSync, renameSync, copyFileSync } from "node:fs"
-import type { ConfigMergeResult, DetectedConfig, InstallConfig, OpenCodeConfig } from "./types"
 import {
+  copyFileSync,
+  existsSync,
+  readFileSync,
+  renameSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs';
+import {
+  ensureConfigDir,
   getConfigDir,
   getExistingConfigPath,
-  ensureConfigDir,
   getLiteConfig,
-} from "./paths"
-import {
-  GOOGLE_PROVIDER_CONFIG,
-  generateLiteConfig,
-} from "./providers"
-import { fetchLatestVersion } from "./system"
-
-const PACKAGE_NAME = "oh-my-opencode-slim"
+} from './paths';
+import { GOOGLE_PROVIDER_CONFIG, generateLiteConfig } from './providers';
+import { fetchLatestVersion } from './system';
+import type {
+  ConfigMergeResult,
+  DetectedConfig,
+  InstallConfig,
+  OpenCodeConfig,
+} from './types';
+
+const PACKAGE_NAME = 'oh-my-opencode-slim';
 
 /**
  * Strip JSON comments (single-line // and multi-line) and trailing commas for JSONC support.
  */
 export function stripJsonComments(json: string): string {
-  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g
-  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g
+  const commentPattern = /\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g;
+  const trailingCommaPattern = /\\"|"(?:\\"|[^"])*"|(,)(\s*[}\]])/g;
 
   return json
-    .replace(commentPattern, (match, commentGroup) => (commentGroup ? "" : match))
-    .replace(trailingCommaPattern, (match, comma, closing) =>
-      comma ? closing : match
+    .replace(commentPattern, (match, commentGroup) =>
+      commentGroup ? '' : match,
     )
+    .replace(trailingCommaPattern, (match, comma, closing) =>
+      comma ? closing : match,
+    );
 }
 
-export function parseConfigFile(path: string): { config: OpenCodeConfig | null; error?: string } {
+export function parseConfigFile(path: string): {
+  config: OpenCodeConfig | null;
+  error?: string;
+} {
   try {
-    if (!existsSync(path)) return { config: null }
-    const stat = statSync(path)
-    if (stat.size === 0) return { config: null }
-    const content = readFileSync(path, "utf-8")
-    if (content.trim().length === 0) return { config: null }
-    return { config: JSON.parse(stripJsonComments(content)) as OpenCodeConfig }
+    if (!existsSync(path)) return { config: null };
+    const stat = statSync(path);
+    if (stat.size === 0) return { config: null };
+    const content = readFileSync(path, 'utf-8');
+    if (content.trim().length === 0) return { config: null };
+    return { config: JSON.parse(stripJsonComments(content)) as OpenCodeConfig };
   } catch (err) {
-    return { config: null, error: String(err) }
+    return { config: null, error: String(err) };
   }
 }
 
-export function parseConfig(path: string): { config: OpenCodeConfig | null; error?: string } {
-  let result = parseConfigFile(path)
-  if (result.config || result.error) return result
+export function parseConfig(path: string): {
+  config: OpenCodeConfig | null;
+  error?: string;
+} {
+  const result = parseConfigFile(path);
+  if (result.config || result.error) return result;
 
-  if (path.endsWith(".json")) {
-    const jsoncPath = path.replace(/\.json$/, ".jsonc")
-    return parseConfigFile(jsoncPath)
+  if (path.endsWith('.json')) {
+    const jsoncPath = path.replace(/\.json$/, '.jsonc');
+    return parseConfigFile(jsoncPath);
   }
-  return { config: null }
+  return { config: null };
 }
 
 /**
  * Write config to file atomically.
  */
 export function writeConfig(configPath: string, config: OpenCodeConfig): void {
-  if (configPath.endsWith(".jsonc")) {
+  if (configPath.endsWith('.jsonc')) {
     console.warn(
-      "[config-manager] Writing to .jsonc file - comments will not be preserved"
-    )
+      '[config-manager] Writing to .jsonc file - comments will not be preserved',
+    );
   }
 
-  const tmpPath = `${configPath}.tmp`
-  const bakPath = `${configPath}.bak`
-  const content = JSON.stringify(config, null, 2) + "\n"
+  const tmpPath = `${configPath}.tmp`;
+  const bakPath = `${configPath}.bak`;
+  const content = `${JSON.stringify(config, null, 2)}\n`;
 
   // Backup existing config if it exists
   if (existsSync(configPath)) {
-    copyFileSync(configPath, bakPath)
+    copyFileSync(configPath, bakPath);
   }
 
   // Atomic write pattern: write to tmp, then rename
-  writeFileSync(tmpPath, content)
-  renameSync(tmpPath, configPath)
+  writeFileSync(tmpPath, content);
+  renameSync(tmpPath, configPath);
 }
 
 export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
   try {
-    ensureConfigDir()
+    ensureConfigDir();
   } catch (err) {
     return {
       success: false,
       configPath: getConfigDir(),
       error: `Failed to create config directory: ${err}`,
-    }
+    };
   }
 
-  const configPath = getExistingConfigPath()
+  const configPath = getExistingConfigPath();
 
   try {
-    const { config: parsedConfig, error } = parseConfig(configPath)
+    const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
-       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+      return {
+        success: false,
+        configPath,
+        error: `Failed to parse config: ${error}`,
+      };
     }
-    const config = parsedConfig ?? {}
-    const plugins = config.plugin ?? []
+    const config = parsedConfig ?? {};
+    const plugins = config.plugin ?? [];
 
     // Remove existing oh-my-opencode-slim entries
     const filteredPlugins = plugins.filter(
-      (p) => p !== PACKAGE_NAME && !p.startsWith(`${PACKAGE_NAME}@`)
-    )
+      (p) => p !== PACKAGE_NAME && !p.startsWith(`${PACKAGE_NAME}@`),
+    );
 
     // Add fresh entry
-    filteredPlugins.push(PACKAGE_NAME)
-    config.plugin = filteredPlugins
+    filteredPlugins.push(PACKAGE_NAME);
+    config.plugin = filteredPlugins;
 
-    writeConfig(configPath, config)
-    return { success: true, configPath }
+    writeConfig(configPath, config);
+    return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
       error: `Failed to update opencode config: ${err}`,
-    }
+    };
   }
 }
 
-export async function addAuthPlugins(installConfig: InstallConfig): Promise<ConfigMergeResult> {
-  const configPath = getExistingConfigPath()
+export async function addAuthPlugins(
+  installConfig: InstallConfig,
+): Promise<ConfigMergeResult> {
+  const configPath = getExistingConfigPath();
 
   try {
-    ensureConfigDir()
-    const { config: parsedConfig, error } = parseConfig(configPath)
+    ensureConfigDir();
+    const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
-       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+      return {
+        success: false,
+        configPath,
+        error: `Failed to parse config: ${error}`,
+      };
     }
-    const config = parsedConfig ?? {}
-    const plugins = config.plugin ?? []
+    const config = parsedConfig ?? {};
+    const plugins = config.plugin ?? [];
 
     if (installConfig.hasAntigravity) {
-      const version = await fetchLatestVersion("opencode-antigravity-auth")
+      const version = await fetchLatestVersion('opencode-antigravity-auth');
       const pluginEntry = version
         ? `opencode-antigravity-auth@${version}`
-        : "opencode-antigravity-auth@latest"
+        : 'opencode-antigravity-auth@latest';
 
-      if (!plugins.some((p) => p.startsWith("opencode-antigravity-auth"))) {
-        plugins.push(pluginEntry)
+      if (!plugins.some((p) => p.startsWith('opencode-antigravity-auth'))) {
+        plugins.push(pluginEntry);
       }
     }
 
-    config.plugin = plugins
-    writeConfig(configPath, config)
-    return { success: true, configPath }
+    config.plugin = plugins;
+    writeConfig(configPath, config);
+    return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
       error: `Failed to add auth plugins: ${err}`,
-    }
+    };
   }
 }
 
-export function addProviderConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getExistingConfigPath()
+export function addProviderConfig(
+  installConfig: InstallConfig,
+): ConfigMergeResult {
+  const configPath = getExistingConfigPath();
 
   try {
-    ensureConfigDir()
-    const { config: parsedConfig, error } = parseConfig(configPath)
+    ensureConfigDir();
+    const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
-       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+      return {
+        success: false,
+        configPath,
+        error: `Failed to parse config: ${error}`,
+      };
     }
-    const config = parsedConfig ?? {}
+    const config = parsedConfig ?? {};
 
     if (installConfig.hasAntigravity) {
-      const providers = (config.provider ?? {}) as Record<string, unknown>
-      providers.google = GOOGLE_PROVIDER_CONFIG.google
-      config.provider = providers
+      const providers = (config.provider ?? {}) as Record<string, unknown>;
+      providers.google = GOOGLE_PROVIDER_CONFIG.google;
+      config.provider = providers;
     }
 
-    writeConfig(configPath, config)
-    return { success: true, configPath }
+    writeConfig(configPath, config);
+    return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
       error: `Failed to add provider config: ${err}`,
-    }
+    };
   }
 }
 
-export function writeLiteConfig(installConfig: InstallConfig): ConfigMergeResult {
-  const configPath = getLiteConfig()
+export function writeLiteConfig(
+  installConfig: InstallConfig,
+): ConfigMergeResult {
+  const configPath = getLiteConfig();
 
   try {
-    ensureConfigDir()
-    const config = generateLiteConfig(installConfig)
-    
+    ensureConfigDir();
+    const config = generateLiteConfig(installConfig);
+
     // Atomic write for lite config too
-    const tmpPath = `${configPath}.tmp`
-    const bakPath = `${configPath}.bak`
-    const content = JSON.stringify(config, null, 2) + "\n"
+    const tmpPath = `${configPath}.tmp`;
+    const bakPath = `${configPath}.bak`;
+    const content = `${JSON.stringify(config, null, 2)}\n`;
 
     // Backup existing config if it exists
     if (existsSync(configPath)) {
-      copyFileSync(configPath, bakPath)
+      copyFileSync(configPath, bakPath);
     }
 
-    writeFileSync(tmpPath, content)
-    renameSync(tmpPath, configPath)
-    
-    return { success: true, configPath }
+    writeFileSync(tmpPath, content);
+    renameSync(tmpPath, configPath);
+
+    return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
       error: `Failed to write lite config: ${err}`,
-    }
+    };
   }
 }
 
 export function disableDefaultAgents(): ConfigMergeResult {
-  const configPath = getExistingConfigPath()
+  const configPath = getExistingConfigPath();
 
   try {
-    ensureConfigDir()
-    const { config: parsedConfig, error } = parseConfig(configPath)
+    ensureConfigDir();
+    const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
-       return { success: false, configPath, error: `Failed to parse config: ${error}` }
+      return {
+        success: false,
+        configPath,
+        error: `Failed to parse config: ${error}`,
+      };
     }
-    const config = parsedConfig ?? {}
+    const config = parsedConfig ?? {};
 
-    const agent = (config.agent ?? {}) as Record<string, unknown>
-    agent.explore = { disable: true }
-    agent.general = { disable: true }
-    config.agent = agent
+    const agent = (config.agent ?? {}) as Record<string, unknown>;
+    agent.explore = { disable: true };
+    agent.general = { disable: true };
+    config.agent = agent;
 
-    writeConfig(configPath, config)
-    return { success: true, configPath }
+    writeConfig(configPath, config);
+    return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
       error: `Failed to disable default agents: ${err}`,
-    }
+    };
   }
 }
 
@@ -244,35 +283,39 @@ export function detectCurrentConfig(): DetectedConfig {
     hasOpenAI: false,
     hasOpencodeZen: false,
     hasTmux: false,
-  }
+  };
 
-  const { config } = parseConfig(getExistingConfigPath())
-  if (!config) return result
+  const { config } = parseConfig(getExistingConfigPath());
+  if (!config) return result;
 
-  const plugins = config.plugin ?? []
-  result.isInstalled = plugins.some((p) => p.startsWith(PACKAGE_NAME))
-  result.hasAntigravity = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
+  const plugins = config.plugin ?? [];
+  result.isInstalled = plugins.some((p) => p.startsWith(PACKAGE_NAME));
+  result.hasAntigravity = plugins.some((p) =>
+    p.startsWith('opencode-antigravity-auth'),
+  );
 
   // Try to detect from lite config
-  const { config: liteConfig } = parseConfig(getLiteConfig())
-  if (liteConfig && typeof liteConfig === "object") {
-    const configObj = liteConfig as Record<string, any>
-    const presetName = configObj.preset as string
-    const presets = configObj.presets as Record<string, any>
-    const agents = presets?.[presetName] as Record<string, { model?: string }> | undefined
+  const { config: liteConfig } = parseConfig(getLiteConfig());
+  if (liteConfig && typeof liteConfig === 'object') {
+    const configObj = liteConfig as Record<string, any>;
+    const presetName = configObj.preset as string;
+    const presets = configObj.presets as Record<string, any>;
+    const agents = presets?.[presetName] as
+      | Record<string, { model?: string }>
+      | undefined;
 
     if (agents) {
       const models = Object.values(agents)
         .map((a) => a?.model)
-        .filter(Boolean)
-      result.hasOpenAI = models.some((m) => m?.startsWith("openai/"))
-      result.hasOpencodeZen = models.some((m) => m?.startsWith("opencode/"))
+        .filter(Boolean);
+      result.hasOpenAI = models.some((m) => m?.startsWith('openai/'));
+      result.hasOpencodeZen = models.some((m) => m?.startsWith('opencode/'));
     }
 
-    if (configObj.tmux && typeof configObj.tmux === "object") {
-      result.hasTmux = configObj.tmux.enabled === true
+    if (configObj.tmux && typeof configObj.tmux === 'object') {
+      result.hasTmux = configObj.tmux.enabled === true;
     }
   }
 
-  return result
+  return result;
 }

+ 95 - 91
src/cli/config-manager.test.ts

@@ -1,59 +1,61 @@
 /// <reference types="bun-types" />
 
-import { describe, expect, test } from "bun:test"
-import { stripJsonComments } from "./config-manager"
+import { describe, expect, test } from 'bun:test';
+import { stripJsonComments } from './config-manager';
 
-describe("config-manager (barrel)", () => {
-  describe("stripJsonComments", () => {
-    test("returns unchanged JSON without comments", () => {
-      const json = '{"key": "value"}'
-      expect(stripJsonComments(json)).toBe(json)
-    })
+describe('config-manager (barrel)', () => {
+  describe('stripJsonComments', () => {
+    test('returns unchanged JSON without comments', () => {
+      const json = '{"key": "value"}';
+      expect(stripJsonComments(json)).toBe(json);
+    });
 
-    test("strips single-line comments", () => {
+    test('strips single-line comments', () => {
       const json = `{
     "key": "value" // this is a comment
-  }`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-    })
+  }`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: 'value' });
+    });
 
-    test("strips multi-line comments", () => {
+    test('strips multi-line comments', () => {
       const json = `{
     /* this is a
        multi-line comment */
     "key": "value"
-  }`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-    })
+  }`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: 'value' });
+    });
 
-    test("strips trailing commas", () => {
+    test('strips trailing commas', () => {
       const json = `{
     "key": "value",
-  }`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-    })
+  }`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: 'value' });
+    });
 
-    test("strips trailing commas in arrays", () => {
+    test('strips trailing commas in arrays', () => {
       const json = `{
     "arr": [1, 2, 3,]
-  }`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ arr: [1, 2, 3] })
-    })
+  }`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ arr: [1, 2, 3] });
+    });
 
-    test("preserves URLs with double slashes", () => {
-      const json = '{"url": "https://example.com"}'
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ url: "https://example.com" })
-    })
+    test('preserves URLs with double slashes', () => {
+      const json = '{"url": "https://example.com"}';
+      expect(JSON.parse(stripJsonComments(json))).toEqual({
+        url: 'https://example.com',
+      });
+    });
 
-    test("preserves strings containing comment-like patterns", () => {
-      const json = '{"code": "// not a comment", "block": "/* also not */"}'
+    test('preserves strings containing comment-like patterns', () => {
+      const json = '{"code": "// not a comment", "block": "/* also not */"}';
       expect(JSON.parse(stripJsonComments(json))).toEqual({
-        code: "// not a comment",
-        block: "/* also not */",
-      })
-    })
+        code: '// not a comment',
+        block: '/* also not */',
+      });
+    });
 
-    test("handles complex JSONC with mixed comments and trailing commas", () => {
+    test('handles complex JSONC with mixed comments and trailing commas', () => {
       const json = `{
     // Configuration for the plugin
     "plugin": ["oh-my-opencode-slim"],
@@ -64,74 +66,76 @@ describe("config-manager (barrel)", () => {
         "name": "Google", // inline comment
       },
     },
-  }`
-      const result = JSON.parse(stripJsonComments(json))
+  }`;
+      const result = JSON.parse(stripJsonComments(json));
       expect(result).toEqual({
-        plugin: ["oh-my-opencode-slim"],
+        plugin: ['oh-my-opencode-slim'],
         provider: {
           google: {
-            name: "Google",
+            name: 'Google',
           },
         },
-      })
-    })
+      });
+    });
 
-    test("handles escaped quotes in strings", () => {
-      const json = '{"message": "He said \\"hello\\""}'
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ message: 'He said "hello"' })
-    })
+    test('handles escaped quotes in strings', () => {
+      const json = '{"message": "He said \\"hello\\""}';
+      expect(JSON.parse(stripJsonComments(json))).toEqual({
+        message: 'He said "hello"',
+      });
+    });
 
-    test("handles empty input", () => {
-      expect(stripJsonComments("")).toBe("")
-    })
+    test('handles empty input', () => {
+      expect(stripJsonComments('')).toBe('');
+    });
 
-    test("handles whitespace-only input", () => {
-      expect(stripJsonComments("   ")).toBe("   ")
-    })
+    test('handles whitespace-only input', () => {
+      expect(stripJsonComments('   ')).toBe('   ');
+    });
 
-    test("handles single-line comment at start of file", () => {
+    test('handles single-line comment at start of file', () => {
       const json = `// comment at start
-  {"key": "value"}`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: "value" })
-    })
+  {"key": "value"}`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ key: 'value' });
+    });
 
-    test("handles comment-only lines between properties", () => {
+    test('handles comment-only lines between properties', () => {
       const json = `{
     "a": 1,
     // comment line
     "b": 2
-  }`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ a: 1, b: 2 })
-    })
-
-    test("handles multiple trailing commas in nested structures", () => {
-      const json = `{"nested": {"a": 1,},}`
-      expect(JSON.parse(stripJsonComments(json))).toEqual({ nested: { a: 1 } })
-    })
-
-    test("handles unclosed string gracefully without throwing", () => {
-      const json = '{"key": "unclosed'
-      expect(() => stripJsonComments(json)).not.toThrow()
-    })
-
-    test("preserves comma-bracket patterns inside strings", () => {
-      const json = '{"script": "test [,]", "json": "{,}"}'
-      const result = JSON.parse(stripJsonComments(json))
-      expect(result.script).toBe("test [,]")
-      expect(result.json).toBe("{,}")
-    })
-
-    test("preserves comma-brace patterns inside strings", () => {
-      const json = '{"glob": "*.{js,ts}", "arr": "[a,]"}'
-      const result = JSON.parse(stripJsonComments(json))
-      expect(result.glob).toBe("*.{js,ts}")
-      expect(result.arr).toBe("[a,]")
-    })
-
-    test("handles Windows CRLF line endings", () => {
-      const json = '{\r\n  "key": "value", // comment\r\n}'
-      const result = JSON.parse(stripJsonComments(json))
-      expect(result).toEqual({ key: "value" })
-    })
-  })
-})
+  }`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ a: 1, b: 2 });
+    });
+
+    test('handles multiple trailing commas in nested structures', () => {
+      const json = `{"nested": {"a": 1,},}`;
+      expect(JSON.parse(stripJsonComments(json))).toEqual({ nested: { a: 1 } });
+    });
+
+    test('handles unclosed string gracefully without throwing', () => {
+      const json = '{"key": "unclosed';
+      expect(() => stripJsonComments(json)).not.toThrow();
+    });
+
+    test('preserves comma-bracket patterns inside strings', () => {
+      const json = '{"script": "test [,]", "json": "{,}"}';
+      const result = JSON.parse(stripJsonComments(json));
+      expect(result.script).toBe('test [,]');
+      expect(result.json).toBe('{,}');
+    });
+
+    test('preserves comma-brace patterns inside strings', () => {
+      const json = '{"glob": "*.{js,ts}", "arr": "[a,]"}';
+      const result = JSON.parse(stripJsonComments(json));
+      expect(result.glob).toBe('*.{js,ts}');
+      expect(result.arr).toBe('[a,]');
+    });
+
+    test('handles Windows CRLF line endings', () => {
+      const json = '{\r\n  "key": "value", // comment\r\n}';
+      const result = JSON.parse(stripJsonComments(json));
+      expect(result).toEqual({ key: 'value' });
+    });
+  });
+});

+ 4 - 4
src/cli/config-manager.ts

@@ -1,4 +1,4 @@
-export * from "./paths"
-export * from "./providers"
-export * from "./system"
-export * from "./config-io"
+export * from './config-io';
+export * from './paths';
+export * from './providers';
+export * from './system';

+ 30 - 30
src/cli/index.ts

@@ -1,28 +1,28 @@
 #!/usr/bin/env bun
-import { install } from "./install"
-import type { InstallArgs, BooleanArg } from "./types"
+import { install } from './install';
+import type { BooleanArg, InstallArgs } from './types';
 
 function parseArgs(args: string[]): InstallArgs {
   const result: InstallArgs = {
     tui: true,
-  }
+  };
 
   for (const arg of args) {
-    if (arg === "--no-tui") {
-      result.tui = false
-    } else if (arg.startsWith("--antigravity=")) {
-      result.antigravity = arg.split("=")[1] as BooleanArg
-    } else if (arg.startsWith("--openai=")) {
-      result.openai = arg.split("=")[1] as BooleanArg
-    } else if (arg.startsWith("--tmux=")) {
-      result.tmux = arg.split("=")[1] as BooleanArg
-    } else if (arg === "-h" || arg === "--help") {
-      printHelp()
-      process.exit(0)
+    if (arg === '--no-tui') {
+      result.tui = false;
+    } else if (arg.startsWith('--antigravity=')) {
+      result.antigravity = arg.split('=')[1] as BooleanArg;
+    } else if (arg.startsWith('--openai=')) {
+      result.openai = arg.split('=')[1] as BooleanArg;
+    } else if (arg.startsWith('--tmux=')) {
+      result.tmux = arg.split('=')[1] as BooleanArg;
+    } else if (arg === '-h' || arg === '--help') {
+      printHelp();
+      process.exit(0);
     }
   }
 
-  return result
+  return result;
 }
 
 function printHelp(): void {
@@ -41,27 +41,27 @@ Options:
 Examples:
   bunx oh-my-opencode-slim install
   bunx oh-my-opencode-slim install --no-tui --antigravity=yes --openai=yes --tmux=no
-`)
+`);
 }
 
 async function main(): Promise<void> {
-  const args = process.argv.slice(2)
+  const args = process.argv.slice(2);
 
-  if (args.length === 0 || args[0] === "install") {
-    const installArgs = parseArgs(args.slice(args[0] === "install" ? 1 : 0))
-    const exitCode = await install(installArgs)
-    process.exit(exitCode)
-  } else if (args[0] === "-h" || args[0] === "--help") {
-    printHelp()
-    process.exit(0)
+  if (args.length === 0 || args[0] === 'install') {
+    const installArgs = parseArgs(args.slice(args[0] === 'install' ? 1 : 0));
+    const exitCode = await install(installArgs);
+    process.exit(exitCode);
+  } else if (args[0] === '-h' || args[0] === '--help') {
+    printHelp();
+    process.exit(0);
   } else {
-    console.error(`Unknown command: ${args[0]}`)
-    console.error("Run with --help for usage information")
-    process.exit(1)
+    console.error(`Unknown command: ${args[0]}`);
+    console.error('Run with --help for usage information');
+    process.exit(1);
   }
 }
 
 main().catch((err) => {
-  console.error("Fatal error:", err)
-  process.exit(1)
-})
+  console.error('Fatal error:', err);
+  process.exit(1);
+});

+ 208 - 151
src/cli/install.ts

@@ -1,25 +1,31 @@
-import type { InstallArgs, InstallConfig, BooleanArg, DetectedConfig, ConfigMergeResult } from "./types"
-import * as readline from "readline/promises"
+import * as readline from 'node:readline/promises';
 import {
-  addPluginToOpenCodeConfig,
-  writeLiteConfig,
-  isOpenCodeInstalled,
-  getOpenCodeVersion,
   addAuthPlugins,
+  addPluginToOpenCodeConfig,
   addProviderConfig,
-  disableDefaultAgents,
   detectCurrentConfig,
+  disableDefaultAgents,
   generateLiteConfig,
-} from "./config-manager"
+  getOpenCodeVersion,
+  isOpenCodeInstalled,
+  writeLiteConfig,
+} from './config-manager';
+import type {
+  BooleanArg,
+  ConfigMergeResult,
+  DetectedConfig,
+  InstallArgs,
+  InstallConfig,
+} from './types';
 
 // Colors
-const GREEN = "\x1b[32m"
-const BLUE = "\x1b[34m"
-const YELLOW = "\x1b[33m"
-const RED = "\x1b[31m"
-const BOLD = "\x1b[1m"
-const DIM = "\x1b[2m"
-const RESET = "\x1b[0m"
+const GREEN = '\x1b[32m';
+const BLUE = '\x1b[34m';
+const YELLOW = '\x1b[33m';
+const RED = '\x1b[31m';
+const BOLD = '\x1b[1m';
+const DIM = '\x1b[2m';
+const RESET = '\x1b[0m';
 
 const SYMBOLS = {
   check: `${GREEN}✓${RESET}`,
@@ -29,132 +35,177 @@ const SYMBOLS = {
   info: `${BLUE}ℹ${RESET}`,
   warn: `${YELLOW}⚠${RESET}`,
   star: `${YELLOW}★${RESET}`,
-}
+};
 
 function printHeader(isUpdate: boolean): void {
-  console.log()
-  console.log(`${BOLD}oh-my-opencode-slim ${isUpdate ? "Update" : "Install"}${RESET}`)
-  console.log("=".repeat(30))
-  console.log()
+  console.log();
+  console.log(
+    `${BOLD}oh-my-opencode-slim ${isUpdate ? 'Update' : 'Install'}${RESET}`,
+  );
+  console.log('='.repeat(30));
+  console.log();
 }
 
 function printStep(step: number, total: number, message: string): void {
-  console.log(`${DIM}[${step}/${total}]${RESET} ${message}`)
+  console.log(`${DIM}[${step}/${total}]${RESET} ${message}`);
 }
 
 function printSuccess(message: string): void {
-  console.log(`${SYMBOLS.check} ${message}`)
+  console.log(`${SYMBOLS.check} ${message}`);
 }
 
 function printError(message: string): void {
-  console.log(`${SYMBOLS.cross} ${RED}${message}${RESET}`)
+  console.log(`${SYMBOLS.cross} ${RED}${message}${RESET}`);
 }
 
 function printInfo(message: string): void {
-  console.log(`${SYMBOLS.info} ${message}`)
+  console.log(`${SYMBOLS.info} ${message}`);
 }
 
 function printWarning(message: string): void {
-  console.log(`${SYMBOLS.warn} ${YELLOW}${message}${RESET}`)
+  console.log(`${SYMBOLS.warn} ${YELLOW}${message}${RESET}`);
 }
 
-async function checkOpenCodeInstalled(): Promise<{ ok: boolean; version?: string }> {
-  const installed = await isOpenCodeInstalled()
+async function checkOpenCodeInstalled(): Promise<{
+  ok: boolean;
+  version?: string;
+}> {
+  const installed = await isOpenCodeInstalled();
   if (!installed) {
-    printError("OpenCode is not installed on this system.")
-    printInfo("Install it with:")
-    console.log(`     ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`)
-    return { ok: false }
+    printError('OpenCode is not installed on this system.');
+    printInfo('Install it with:');
+    console.log(
+      `     ${BLUE}curl -fsSL https://opencode.ai/install | bash${RESET}`,
+    );
+    return { ok: false };
   }
-  const version = await getOpenCodeVersion()
-  printSuccess(`OpenCode ${version ?? ""} detected`)
-  return { ok: true, version: version ?? undefined }
+  const version = await getOpenCodeVersion();
+  printSuccess(`OpenCode ${version ?? ''} detected`);
+  return { ok: true, version: version ?? undefined };
 }
 
-function handleStepResult(result: ConfigMergeResult, successMsg: string): boolean {
+function handleStepResult(
+  result: ConfigMergeResult,
+  successMsg: string,
+): boolean {
   if (!result.success) {
-    printError(`Failed: ${result.error}`)
-    return false
+    printError(`Failed: ${result.error}`);
+    return false;
   }
-  printSuccess(`${successMsg} ${SYMBOLS.arrow} ${DIM}${result.configPath}${RESET}`)
-  return true
+  printSuccess(
+    `${successMsg} ${SYMBOLS.arrow} ${DIM}${result.configPath}${RESET}`,
+  );
+  return true;
 }
 
 function formatConfigSummary(config: InstallConfig): string {
-  const liteConfig = generateLiteConfig(config)
-  const preset = (liteConfig.preset as string) || "unknown"
-
-  const lines: string[] = []
-  lines.push(`${BOLD}Configuration Summary${RESET}`)
-  lines.push("")
-  lines.push(`  ${BOLD}Preset:${RESET} ${BLUE}${preset}${RESET}`)
-  lines.push(`  ${config.hasAntigravity ? SYMBOLS.check : DIM + "○" + RESET} Antigravity`)
-  lines.push(`  ${config.hasOpenAI ? SYMBOLS.check : DIM + "○" + RESET} OpenAI`)
-  lines.push(`  ${SYMBOLS.check} Opencode Zen (free models)`) // Always enabled
-  lines.push(`  ${config.hasTmux ? SYMBOLS.check : DIM + "○" + RESET} Tmux Integration`)
-  return lines.join("\n")
+  const liteConfig = generateLiteConfig(config);
+  const preset = (liteConfig.preset as string) || 'unknown';
+
+  const lines: string[] = [];
+  lines.push(`${BOLD}Configuration Summary${RESET}`);
+  lines.push('');
+  lines.push(`  ${BOLD}Preset:${RESET} ${BLUE}${preset}${RESET}`);
+  lines.push(
+    `  ${config.hasAntigravity ? SYMBOLS.check : `${DIM}○${RESET}`} Antigravity`,
+  );
+  lines.push(
+    `  ${config.hasOpenAI ? SYMBOLS.check : `${DIM}○${RESET}`} OpenAI`,
+  );
+  lines.push(`  ${SYMBOLS.check} Opencode Zen (free models)`); // Always enabled
+  lines.push(
+    `  ${config.hasTmux ? SYMBOLS.check : `${DIM}○${RESET}`} Tmux Integration`,
+  );
+  return lines.join('\n');
 }
 
 function printAgentModels(config: InstallConfig): void {
-  const liteConfig = generateLiteConfig(config)
-  const presetName = (liteConfig.preset as string) || "unknown"
-  const presets = liteConfig.presets as Record<string, any>
-  const agents = presets?.[presetName] as Record<string, { model: string; skills: string[] }>
+  const liteConfig = generateLiteConfig(config);
+  const presetName = (liteConfig.preset as string) || 'unknown';
+  const presets = liteConfig.presets as Record<string, any>;
+  const agents = presets?.[presetName] as Record<
+    string,
+    { model: string; skills: string[] }
+  >;
 
-  if (!agents || Object.keys(agents).length === 0) return
+  if (!agents || Object.keys(agents).length === 0) return;
 
-  console.log(`${BOLD}Agent Configuration (Preset: ${BLUE}${presetName}${RESET}):${RESET}`)
-  console.log()
+  console.log(
+    `${BOLD}Agent Configuration (Preset: ${BLUE}${presetName}${RESET}):${RESET}`,
+  );
+  console.log();
 
-  const maxAgentLen = Math.max(...Object.keys(agents).map((a) => a.length))
+  const maxAgentLen = Math.max(...Object.keys(agents).map((a) => a.length));
 
   for (const [agent, info] of Object.entries(agents)) {
-    const padding = " ".repeat(maxAgentLen - agent.length)
-    const skillsStr = info.skills.length > 0 ? ` ${DIM}[${info.skills.join(", ")}]${RESET}` : ""
-    console.log(`  ${DIM}${agent}${RESET}${padding} ${SYMBOLS.arrow} ${BLUE}${info.model}${RESET}${skillsStr}`)
+    const padding = ' '.repeat(maxAgentLen - agent.length);
+    const skillsStr =
+      info.skills.length > 0
+        ? ` ${DIM}[${info.skills.join(', ')}]${RESET}`
+        : '';
+    console.log(
+      `  ${DIM}${agent}${RESET}${padding} ${SYMBOLS.arrow} ${BLUE}${info.model}${RESET}${skillsStr}`,
+    );
   }
-  console.log()
+  console.log();
 }
 
 function argsToConfig(args: InstallArgs): InstallConfig {
   return {
-    hasAntigravity: args.antigravity === "yes",
-    hasOpenAI: args.openai === "yes",
+    hasAntigravity: args.antigravity === 'yes',
+    hasOpenAI: args.openai === 'yes',
     hasOpencodeZen: true, // Always enabled - free models available to all users
-    hasTmux: args.tmux === "yes",
-  }
+    hasTmux: args.tmux === 'yes',
+  };
 }
 
 async function askYesNo(
   rl: readline.Interface,
   prompt: string,
-  defaultValue: BooleanArg = "no"
+  defaultValue: BooleanArg = 'no',
 ): Promise<BooleanArg> {
-  const hint = defaultValue === "yes" ? "[Y/n]" : "[y/N]"
-  const answer = (await rl.question(`${BLUE}${prompt}${RESET} ${hint}: `)).trim().toLowerCase()
-
-  if (answer === "") return defaultValue
-  if (answer === "y" || answer === "yes") return "yes"
-  if (answer === "n" || answer === "no") return "no"
-  return defaultValue
+  const hint = defaultValue === 'yes' ? '[Y/n]' : '[y/N]';
+  const answer = (await rl.question(`${BLUE}${prompt}${RESET} ${hint}: `))
+    .trim()
+    .toLowerCase();
+
+  if (answer === '') return defaultValue;
+  if (answer === 'y' || answer === 'yes') return 'yes';
+  if (answer === 'n' || answer === 'no') return 'no';
+  return defaultValue;
 }
 
-async function runInteractiveMode(detected: DetectedConfig): Promise<InstallConfig> {
-  const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
+async function runInteractiveMode(
+  detected: DetectedConfig,
+): Promise<InstallConfig> {
+  const rl = readline.createInterface({
+    input: process.stdin,
+    output: process.stdout,
+  });
   // TODO: tmux has a bug, disabled for now
   // const tmuxInstalled = await isTmuxInstalled()
   // const totalQuestions = tmuxInstalled ? 3 : 2
-  const totalQuestions = 2
+  const totalQuestions = 2;
 
   try {
-    console.log(`${BOLD}Question 1/${totalQuestions}:${RESET}`)
-    printInfo("The Pantheon is tuned for Antigravity's model routing. Other models work, but results may vary.")
-    const antigravity = await askYesNo(rl, "Do you have an Antigravity subscription?", "yes")
-    console.log()
-
-    console.log(`${BOLD}Question 2/${totalQuestions}:${RESET}`)
-    const openai = await askYesNo(rl, "Do you have access to OpenAI API?", detected.hasOpenAI ? "yes" : "no")
-    console.log()
+    console.log(`${BOLD}Question 1/${totalQuestions}:${RESET}`);
+    printInfo(
+      "The Pantheon is tuned for Antigravity's model routing. Other models work, but results may vary.",
+    );
+    const antigravity = await askYesNo(
+      rl,
+      'Do you have an Antigravity subscription?',
+      'yes',
+    );
+    console.log();
+
+    console.log(`${BOLD}Question 2/${totalQuestions}:${RESET}`);
+    const openai = await askYesNo(
+      rl,
+      'Do you have access to OpenAI API?',
+      detected.hasOpenAI ? 'yes' : 'no',
+    );
+    console.log();
 
     // TODO: tmux has a bug, disabled for now
     // let tmux: BooleanArg = "no"
@@ -167,74 +218,78 @@ async function runInteractiveMode(detected: DetectedConfig): Promise<InstallConf
     // }
 
     return {
-      hasAntigravity: antigravity === "yes",
-      hasOpenAI: openai === "yes",
+      hasAntigravity: antigravity === 'yes',
+      hasOpenAI: openai === 'yes',
       hasOpencodeZen: true,
       hasTmux: false,
-    }
+    };
   } finally {
-    rl.close()
+    rl.close();
   }
 }
 
 async function runInstall(config: InstallConfig): Promise<number> {
-  const detected = detectCurrentConfig()
-  const isUpdate = detected.isInstalled
+  const detected = detectCurrentConfig();
+  const isUpdate = detected.isInstalled;
 
-  printHeader(isUpdate)
+  printHeader(isUpdate);
 
   // Calculate total steps dynamically
-  let totalSteps = 4 // Base: check opencode, add plugin, disable default agents, write lite config
-  if (config.hasAntigravity) totalSteps += 2 // auth plugins + provider config
+  let totalSteps = 4; // Base: check opencode, add plugin, disable default agents, write lite config
+  if (config.hasAntigravity) totalSteps += 2; // auth plugins + provider config
 
-  let step = 1
+  let step = 1;
 
-  printStep(step++, totalSteps, "Checking OpenCode installation...")
-  const { ok } = await checkOpenCodeInstalled()
-  if (!ok) return 1
+  printStep(step++, totalSteps, 'Checking OpenCode installation...');
+  const { ok } = await checkOpenCodeInstalled();
+  if (!ok) return 1;
 
-  printStep(step++, totalSteps, "Adding oh-my-opencode-slim plugin...")
-  const pluginResult = await addPluginToOpenCodeConfig()
-  if (!handleStepResult(pluginResult, "Plugin added")) return 1
+  printStep(step++, totalSteps, 'Adding oh-my-opencode-slim plugin...');
+  const pluginResult = await addPluginToOpenCodeConfig();
+  if (!handleStepResult(pluginResult, 'Plugin added')) return 1;
 
-  printStep(step++, totalSteps, "Disabling OpenCode default agents...")
-  const agentResult = disableDefaultAgents()
-  if (!handleStepResult(agentResult, "Default agents disabled")) return 1
+  printStep(step++, totalSteps, 'Disabling OpenCode default agents...');
+  const agentResult = disableDefaultAgents();
+  if (!handleStepResult(agentResult, 'Default agents disabled')) return 1;
 
   if (config.hasAntigravity) {
-    printStep(step++, totalSteps, "Adding auth plugins...")
-    const authResult = await addAuthPlugins(config)
-    if (!handleStepResult(authResult, "Auth plugins configured")) return 1
+    printStep(step++, totalSteps, 'Adding auth plugins...');
+    const authResult = await addAuthPlugins(config);
+    if (!handleStepResult(authResult, 'Auth plugins configured')) return 1;
 
-    printStep(step++, totalSteps, "Adding provider configurations...")
-    const providerResult = addProviderConfig(config)
-    if (!handleStepResult(providerResult, "Providers configured")) return 1
+    printStep(step++, totalSteps, 'Adding provider configurations...');
+    const providerResult = addProviderConfig(config);
+    if (!handleStepResult(providerResult, 'Providers configured')) return 1;
   }
 
-  printStep(step++, totalSteps, "Writing oh-my-opencode-slim configuration...")
-  const liteResult = writeLiteConfig(config)
-  if (!handleStepResult(liteResult, "Config written")) return 1
+  printStep(step++, totalSteps, 'Writing oh-my-opencode-slim configuration...');
+  const liteResult = writeLiteConfig(config);
+  if (!handleStepResult(liteResult, 'Config written')) return 1;
 
   // Summary
-  console.log()
-  console.log(formatConfigSummary(config))
-  console.log()
+  console.log();
+  console.log(formatConfigSummary(config));
+  console.log();
 
-  printAgentModels(config)
+  printAgentModels(config);
 
   if (!config.hasAntigravity && !config.hasOpenAI) {
-    printWarning("No providers configured. Zen free models will be used as fallback.")
+    printWarning(
+      'No providers configured. Zen free models will be used as fallback.',
+    );
   }
 
-  console.log(`${SYMBOLS.star} ${BOLD}${GREEN}${isUpdate ? "Configuration updated!" : "Installation complete!"}${RESET}`)
-  console.log()
-  console.log(`${BOLD}Next steps:${RESET}`)
-  console.log()
+  console.log(
+    `${SYMBOLS.star} ${BOLD}${GREEN}${isUpdate ? 'Configuration updated!' : 'Installation complete!'}${RESET}`,
+  );
+  console.log();
+  console.log(`${BOLD}Next steps:${RESET}`);
+  console.log();
 
-  let nextStep = 1
-  console.log(`  ${nextStep++}. Authenticate with your providers:`)
-  console.log(`     ${BLUE}$ opencode auth login${RESET}`)
-  console.log()
+  let nextStep = 1;
+  console.log(`  ${nextStep++}. Authenticate with your providers:`);
+  console.log(`     ${BLUE}$ opencode auth login${RESET}`);
+  console.log();
 
   // TODO: tmux has a bug, disabled for now
   // if (config.hasTmux) {
@@ -242,48 +297,50 @@ async function runInstall(config: InstallConfig): Promise<number> {
   //   console.log(`     ${BLUE}$ tmux${RESET}`)
   //   console.log(`     ${BLUE}$ opencode${RESET}`)
   // } else {
-  console.log(`  ${nextStep++}. Start OpenCode:`)
-  console.log(`     ${BLUE}$ opencode${RESET}`)
+  console.log(`  ${nextStep++}. Start OpenCode:`);
+  console.log(`     ${BLUE}$ opencode${RESET}`);
   // }
-  console.log()
+  console.log();
 
-  return 0
+  return 0;
 }
 
 export async function install(args: InstallArgs): Promise<number> {
   // Non-interactive mode: all args must be provided
   if (!args.tui) {
-    const requiredArgs = ["antigravity", "openai", "tmux"] as const
+    const requiredArgs = ['antigravity', 'openai', 'tmux'] as const;
     const errors = requiredArgs.filter((key) => {
-      const value = args[key]
-      return value === undefined || !["yes", "no"].includes(value)
-    })
+      const value = args[key];
+      return value === undefined || !['yes', 'no'].includes(value);
+    });
 
     if (errors.length > 0) {
-      printHeader(false)
-      printError("Missing or invalid arguments:")
+      printHeader(false);
+      printError('Missing or invalid arguments:');
       for (const key of errors) {
-        console.log(`  ${SYMBOLS.bullet} --${key}=<yes|no>`)
+        console.log(`  ${SYMBOLS.bullet} --${key}=<yes|no>`);
       }
-      console.log()
-      printInfo("Usage: bunx oh-my-opencode-slim install --no-tui --antigravity=<yes|no> --openai=<yes|no> --tmux=<yes|no>")
-      console.log()
-      return 1
+      console.log();
+      printInfo(
+        'Usage: bunx oh-my-opencode-slim install --no-tui --antigravity=<yes|no> --openai=<yes|no> --tmux=<yes|no>',
+      );
+      console.log();
+      return 1;
     }
 
-    return runInstall(argsToConfig(args))
+    return runInstall(argsToConfig(args));
   }
 
   // Interactive mode
-  const detected = detectCurrentConfig()
+  const detected = detectCurrentConfig();
 
-  printHeader(detected.isInstalled)
+  printHeader(detected.isInstalled);
 
-  printStep(1, 1, "Checking OpenCode installation...")
-  const { ok } = await checkOpenCodeInstalled()
-  if (!ok) return 1
-  console.log()
+  printStep(1, 1, 'Checking OpenCode installation...');
+  const { ok } = await checkOpenCodeInstalled();
+  if (!ok) return 1;
+  console.log();
 
-  const config = await runInteractiveMode(detected)
-  return runInstall(config)
+  const config = await runInteractiveMode(detected);
+  return runInstall(config);
 }

+ 92 - 91
src/cli/paths.test.ts

@@ -1,114 +1,115 @@
 /// <reference types="bun-types" />
 
-import { describe, expect, test, afterEach } from "bun:test"
+import { afterEach, describe, expect, test } from 'bun:test';
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { homedir, tmpdir } from 'node:os';
+import { join } from 'node:path';
 import {
+  ensureConfigDir,
   getConfigDir,
-  getOpenCodeConfigPaths,
   getConfigJson,
   getConfigJsonc,
-  getLiteConfig,
   getExistingConfigPath,
-  ensureConfigDir,
-} from "./paths"
-import { homedir } from "node:os"
-import { join } from "node:path"
-import { existsSync, rmSync, mkdtempSync, writeFileSync } from "node:fs"
-import { tmpdir } from "node:os"
+  getLiteConfig,
+  getOpenCodeConfigPaths,
+} from './paths';
 
-describe("paths", () => {
-  const originalEnv = { ...process.env }
+describe('paths', () => {
+  const originalEnv = { ...process.env };
 
   afterEach(() => {
-    process.env = { ...originalEnv }
-  })
-
-  test("getConfigDir() uses XDG_CONFIG_HOME when set", () => {
-    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
-    expect(getConfigDir()).toBe("/tmp/xdg-config/opencode")
-  })
-
-  test("getConfigDir() falls back to ~/.config when XDG_CONFIG_HOME is unset", () => {
-    delete process.env.XDG_CONFIG_HOME
-    const expected = join(homedir(), ".config", "opencode")
-    expect(getConfigDir()).toBe(expected)
-  })
-
-  test("getOpenCodeConfigPaths() returns both json and jsonc paths", () => {
-    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
+    process.env = { ...originalEnv };
+  });
+
+  test('getConfigDir() uses XDG_CONFIG_HOME when set', () => {
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
+    expect(getConfigDir()).toBe('/tmp/xdg-config/opencode');
+  });
+
+  test('getConfigDir() falls back to ~/.config when XDG_CONFIG_HOME is unset', () => {
+    delete process.env.XDG_CONFIG_HOME;
+    const expected = join(homedir(), '.config', 'opencode');
+    expect(getConfigDir()).toBe(expected);
+  });
+
+  test('getOpenCodeConfigPaths() returns both json and jsonc paths', () => {
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
     expect(getOpenCodeConfigPaths()).toEqual([
-      "/tmp/xdg-config/opencode/opencode.json",
-      "/tmp/xdg-config/opencode/opencode.jsonc",
-    ])
-  })
+      '/tmp/xdg-config/opencode/opencode.json',
+      '/tmp/xdg-config/opencode/opencode.jsonc',
+    ]);
+  });
 
-  test("getConfigJson() returns correct path", () => {
-    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
-    expect(getConfigJson()).toBe("/tmp/xdg-config/opencode/opencode.json")
-  })
+  test('getConfigJson() returns correct path', () => {
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
+    expect(getConfigJson()).toBe('/tmp/xdg-config/opencode/opencode.json');
+  });
 
-  test("getConfigJsonc() returns correct path", () => {
-    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
-    expect(getConfigJsonc()).toBe("/tmp/xdg-config/opencode/opencode.jsonc")
-  })
+  test('getConfigJsonc() returns correct path', () => {
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
+    expect(getConfigJsonc()).toBe('/tmp/xdg-config/opencode/opencode.jsonc');
+  });
 
-  test("getLiteConfig() returns correct path", () => {
-    process.env.XDG_CONFIG_HOME = "/tmp/xdg-config"
-    expect(getLiteConfig()).toBe("/tmp/xdg-config/opencode/oh-my-opencode-slim.json")
-  })
+  test('getLiteConfig() returns correct path', () => {
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
+    expect(getLiteConfig()).toBe(
+      '/tmp/xdg-config/opencode/oh-my-opencode-slim.json',
+    );
+  });
 
-  describe("getExistingConfigPath()", () => {
-    let tmpDir: string
+  describe('getExistingConfigPath()', () => {
+    let tmpDir: string;
 
     afterEach(() => {
       if (tmpDir && existsSync(tmpDir)) {
-        rmSync(tmpDir, { recursive: true, force: true })
+        rmSync(tmpDir, { recursive: true, force: true });
       }
-    })
-
-    test("returns .json if it exists", () => {
-      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
-      process.env.XDG_CONFIG_HOME = tmpDir
-      
-      const configDir = join(tmpDir, "opencode")
-      ensureConfigDir()
-      
-      const jsonPath = join(configDir, "opencode.json")
-      writeFileSync(jsonPath, "{}")
-      
-      expect(getExistingConfigPath()).toBe(jsonPath)
-    })
+    });
+
+    test('returns .json if it exists', () => {
+      tmpDir = mkdtempSync(join(tmpdir(), 'opencode-test-'));
+      process.env.XDG_CONFIG_HOME = tmpDir;
+
+      const configDir = join(tmpDir, 'opencode');
+      ensureConfigDir();
+
+      const jsonPath = join(configDir, 'opencode.json');
+      writeFileSync(jsonPath, '{}');
+
+      expect(getExistingConfigPath()).toBe(jsonPath);
+    });
 
     test("returns .jsonc if .json doesn't exist but .jsonc does", () => {
-      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
-      process.env.XDG_CONFIG_HOME = tmpDir
-      
-      const configDir = join(tmpDir, "opencode")
-      ensureConfigDir()
-      
-      const jsoncPath = join(configDir, "opencode.jsonc")
-      writeFileSync(jsoncPath, "{}")
-      
-      expect(getExistingConfigPath()).toBe(jsoncPath)
-    })
-
-    test("returns default .json if neither exists", () => {
-      tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
-      process.env.XDG_CONFIG_HOME = tmpDir
-      
-      const jsonPath = join(tmpDir, "opencode", "opencode.json")
-      expect(getExistingConfigPath()).toBe(jsonPath)
-    })
-  })
+      tmpDir = mkdtempSync(join(tmpdir(), 'opencode-test-'));
+      process.env.XDG_CONFIG_HOME = tmpDir;
+
+      const configDir = join(tmpDir, 'opencode');
+      ensureConfigDir();
+
+      const jsoncPath = join(configDir, 'opencode.jsonc');
+      writeFileSync(jsoncPath, '{}');
+
+      expect(getExistingConfigPath()).toBe(jsoncPath);
+    });
+
+    test('returns default .json if neither exists', () => {
+      tmpDir = mkdtempSync(join(tmpdir(), 'opencode-test-'));
+      process.env.XDG_CONFIG_HOME = tmpDir;
+
+      const jsonPath = join(tmpDir, 'opencode', 'opencode.json');
+      expect(getExistingConfigPath()).toBe(jsonPath);
+    });
+  });
 
   test("ensureConfigDir() creates directory if it doesn't exist", () => {
-    const tmpDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
-    process.env.XDG_CONFIG_HOME = tmpDir
-    const configDir = join(tmpDir, "opencode")
-    
-    expect(existsSync(configDir)).toBe(false)
-    ensureConfigDir()
-    expect(existsSync(configDir)).toBe(true)
-    
-    rmSync(tmpDir, { recursive: true, force: true })
-  })
-})
+    const tmpDir = mkdtempSync(join(tmpdir(), 'opencode-test-'));
+    process.env.XDG_CONFIG_HOME = tmpDir;
+    const configDir = join(tmpDir, 'opencode');
+
+    expect(existsSync(configDir)).toBe(false);
+    ensureConfigDir();
+    expect(existsSync(configDir)).toBe(true);
+
+    rmSync(tmpDir, { recursive: true, force: true });
+  });
+});

+ 17 - 20
src/cli/paths.ts

@@ -1,50 +1,47 @@
-import { existsSync, mkdirSync } from "node:fs"
-import { homedir } from "node:os"
-import { join } from "node:path"
+import { existsSync, mkdirSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
 
 export function getConfigDir(): string {
   // Keep this aligned with OpenCode itself and the plugin config loader:
   // base dir is $XDG_CONFIG_HOME (if set) else ~/.config, and OpenCode config lives under /opencode.
   const userConfigDir = process.env.XDG_CONFIG_HOME
     ? process.env.XDG_CONFIG_HOME
-    : join(homedir(), ".config")
+    : join(homedir(), '.config');
 
-  return join(userConfigDir, "opencode")
+  return join(userConfigDir, 'opencode');
 }
 
 export function getOpenCodeConfigPaths(): string[] {
-  const configDir = getConfigDir()
-  return [
-    join(configDir, "opencode.json"),
-    join(configDir, "opencode.jsonc"),
-  ]
+  const configDir = getConfigDir();
+  return [join(configDir, 'opencode.json'), join(configDir, 'opencode.jsonc')];
 }
 
 export function getConfigJson(): string {
-  return join(getConfigDir(), "opencode.json")
+  return join(getConfigDir(), 'opencode.json');
 }
 
 export function getConfigJsonc(): string {
-  return join(getConfigDir(), "opencode.jsonc")
+  return join(getConfigDir(), 'opencode.jsonc');
 }
 
 export function getLiteConfig(): string {
-  return join(getConfigDir(), "oh-my-opencode-slim.json")
+  return join(getConfigDir(), 'oh-my-opencode-slim.json');
 }
 
 export function getExistingConfigPath(): string {
-  const jsonPath = getConfigJson()
-  if (existsSync(jsonPath)) return jsonPath
+  const jsonPath = getConfigJson();
+  if (existsSync(jsonPath)) return jsonPath;
 
-  const jsoncPath = getConfigJsonc()
-  if (existsSync(jsoncPath)) return jsoncPath
+  const jsoncPath = getConfigJsonc();
+  if (existsSync(jsoncPath)) return jsoncPath;
 
-  return jsonPath
+  return jsonPath;
 }
 
 export function ensureConfigDir(): void {
-  const configDir = getConfigDir()
+  const configDir = getConfigDir();
   if (!existsSync(configDir)) {
-    mkdirSync(configDir, { recursive: true })
+    mkdirSync(configDir, { recursive: true });
   }
 }

+ 62 - 54
src/cli/providers.test.ts

@@ -1,105 +1,113 @@
 /// <reference types="bun-types" />
 
-import { describe, expect, test } from "bun:test"
-import { generateLiteConfig, MODEL_MAPPINGS } from "./providers"
+import { describe, expect, test } from 'bun:test';
+import { generateLiteConfig, MODEL_MAPPINGS } from './providers';
 
-describe("providers", () => {
-  test("generateLiteConfig generates antigravity config by default", () => {
+describe('providers', () => {
+  test('generateLiteConfig generates antigravity config by default', () => {
     const config = generateLiteConfig({
       hasAntigravity: true,
       hasOpenAI: false,
       hasOpencodeZen: false,
       hasTmux: false,
-    })
+    });
 
-    expect(config.preset).toBe("antigravity")
-    const agents = (config.presets as any).antigravity
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator.model)
-    expect(agents.orchestrator.variant).toBeUndefined()
-    expect(agents.fixer.model).toBe(MODEL_MAPPINGS.antigravity.fixer.model)
-    expect(agents.fixer.variant).toBe(MODEL_MAPPINGS.antigravity.fixer.variant)
-  })
+    expect(config.preset).toBe('antigravity');
+    const agents = (config.presets as any).antigravity;
+    expect(agents.orchestrator.model).toBe(
+      MODEL_MAPPINGS.antigravity.orchestrator.model,
+    );
+    expect(agents.orchestrator.variant).toBeUndefined();
+    expect(agents.fixer.model).toBe(MODEL_MAPPINGS.antigravity.fixer.model);
+    expect(agents.fixer.variant).toBe(MODEL_MAPPINGS.antigravity.fixer.variant);
+  });
 
-  test("generateLiteConfig always includes antigravity-openai preset", () => {
+  test('generateLiteConfig always includes antigravity-openai preset', () => {
     const config = generateLiteConfig({
       hasAntigravity: true,
       hasOpenAI: true,
       hasOpencodeZen: false,
       hasTmux: false,
-    })
+    });
 
-    expect(config.preset).toBe("antigravity-openai")
-    const agents = (config.presets as any)["antigravity-openai"]
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.antigravity.orchestrator.model)
-    expect(agents.orchestrator.variant).toBeUndefined()
-    expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
-    expect(agents.oracle.variant).toBe("high")
-  })
+    expect(config.preset).toBe('antigravity-openai');
+    const agents = (config.presets as any)['antigravity-openai'];
+    expect(agents.orchestrator.model).toBe(
+      MODEL_MAPPINGS.antigravity.orchestrator.model,
+    );
+    expect(agents.orchestrator.variant).toBeUndefined();
+    expect(agents.oracle.model).toBe('openai/gpt-5.2-codex');
+    expect(agents.oracle.variant).toBe('high');
+  });
 
-  test("generateLiteConfig includes antigravity-openai preset even with only antigravity", () => {
+  test('generateLiteConfig includes antigravity-openai preset even with only antigravity', () => {
     const config = generateLiteConfig({
       hasAntigravity: true,
       hasOpenAI: false,
       hasOpencodeZen: false,
       hasTmux: false,
-    })
+    });
 
-    expect(config.preset).toBe("antigravity")
-    const agents = (config.presets as any)["antigravity-openai"]
-    expect(agents).toBeDefined()
-    expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
-  })
+    expect(config.preset).toBe('antigravity');
+    const agents = (config.presets as any)['antigravity-openai'];
+    expect(agents).toBeDefined();
+    expect(agents.oracle.model).toBe('openai/gpt-5.2-codex');
+  });
 
-  test("generateLiteConfig uses openai if no antigravity", () => {
+  test('generateLiteConfig uses openai if no antigravity', () => {
     const config = generateLiteConfig({
       hasAntigravity: false,
       hasOpenAI: true,
       hasOpencodeZen: false,
       hasTmux: false,
-    })
+    });
 
-    expect(config.preset).toBe("openai")
-    const agents = (config.presets as any).openai
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS.openai.orchestrator.model)
-    expect(agents.orchestrator.variant).toBeUndefined()
-  })
+    expect(config.preset).toBe('openai');
+    const agents = (config.presets as any).openai;
+    expect(agents.orchestrator.model).toBe(
+      MODEL_MAPPINGS.openai.orchestrator.model,
+    );
+    expect(agents.orchestrator.variant).toBeUndefined();
+  });
 
-  test("generateLiteConfig uses zen-free if no antigravity or openai", () => {
+  test('generateLiteConfig uses zen-free if no antigravity or openai', () => {
     const config = generateLiteConfig({
       hasAntigravity: false,
       hasOpenAI: false,
       hasOpencodeZen: true,
       hasTmux: false,
-    })
+    });
 
-    expect(config.preset).toBe("zen-free")
-    const agents = (config.presets as any)["zen-free"]
-    expect(agents.orchestrator.model).toBe(MODEL_MAPPINGS["zen-free"].orchestrator.model)
-    expect(agents.orchestrator.variant).toBeUndefined()
-  })
+    expect(config.preset).toBe('zen-free');
+    const agents = (config.presets as any)['zen-free'];
+    expect(agents.orchestrator.model).toBe(
+      MODEL_MAPPINGS['zen-free'].orchestrator.model,
+    );
+    expect(agents.orchestrator.variant).toBeUndefined();
+  });
 
-  test("generateLiteConfig enables tmux when requested", () => {
+  test('generateLiteConfig enables tmux when requested', () => {
     const config = generateLiteConfig({
       hasAntigravity: false,
       hasOpenAI: false,
       hasOpencodeZen: false,
       hasTmux: true,
-    })
+    });
 
-    expect(config.tmux).toBeDefined()
-    expect((config.tmux as any).enabled).toBe(true)
-  })
+    expect(config.tmux).toBeDefined();
+    expect((config.tmux as any).enabled).toBe(true);
+  });
 
-  test("generateLiteConfig includes default skills", () => {
+  test('generateLiteConfig includes default skills', () => {
     const config = generateLiteConfig({
       hasAntigravity: true,
       hasOpenAI: false,
       hasOpencodeZen: false,
       hasTmux: false,
-    })
+    });
 
-    const agents = (config.presets as any).antigravity
-    expect(agents.orchestrator.skills).toContain("*")
-    expect(agents.fixer.skills).toBeDefined()
-  })
-})
+    const agents = (config.presets as any).antigravity;
+    expect(agents.orchestrator.skills).toContain('*');
+    expect(agents.fixer.skills).toBeDefined();
+  });
+});

+ 62 - 50
src/cli/providers.ts

@@ -1,77 +1,79 @@
-import type { InstallConfig } from "./types"
-import { DEFAULT_AGENT_SKILLS } from "../tools/skill/builtin"
+import { DEFAULT_AGENT_SKILLS } from '../tools/skill/builtin';
+import type { InstallConfig } from './types';
 
 /**
  * Provider configurations for Google models (via Antigravity auth plugin)
  */
 export const GOOGLE_PROVIDER_CONFIG = {
   google: {
-    name: "Google",
+    name: 'Google',
     models: {
-      "gemini-3-pro-high": {
-        name: "Gemini 3 Pro High",
+      'gemini-3-pro-high': {
+        name: 'Gemini 3 Pro High',
         thinking: true,
         attachment: true,
         limit: { context: 1048576, output: 65535 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+        modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
       },
-      "gemini-3-flash": {
-        name: "Gemini 3 Flash",
+      'gemini-3-flash': {
+        name: 'Gemini 3 Flash',
         attachment: true,
         limit: { context: 1048576, output: 65536 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+        modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
       },
-      "claude-opus-4-5-thinking": {
-        name: "Claude Opus 4.5 Thinking",
+      'claude-opus-4-5-thinking': {
+        name: 'Claude Opus 4.5 Thinking',
         attachment: true,
         limit: { context: 200000, output: 32000 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+        modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
       },
-      "claude-sonnet-4-5-thinking": {
-        name: "Claude Sonnet 4.5 Thinking",
+      'claude-sonnet-4-5-thinking': {
+        name: 'Claude Sonnet 4.5 Thinking',
         attachment: true,
         limit: { context: 200000, output: 32000 },
-        modalities: { input: ["text", "image", "pdf"], output: ["text"] },
+        modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
       },
     },
   },
-}
+};
 
 // Model mappings by provider priority
 export const MODEL_MAPPINGS = {
   antigravity: {
-    orchestrator: { model: "google/claude-opus-4-5-thinking" },
-    oracle: { model: "google/claude-opus-4-5-thinking", variant: "high" },
-    librarian: { model: "google/gemini-3-flash", variant: "low" },
-    explorer: { model: "google/gemini-3-flash", variant: "low" },
-    designer: { model: "google/gemini-3-flash", variant: "medium" },
-    fixer: { model: "google/gemini-3-flash", variant: "low" },
+    orchestrator: { model: 'google/claude-opus-4-5-thinking' },
+    oracle: { model: 'google/claude-opus-4-5-thinking', variant: 'high' },
+    librarian: { model: 'google/gemini-3-flash', variant: 'low' },
+    explorer: { model: 'google/gemini-3-flash', variant: 'low' },
+    designer: { model: 'google/gemini-3-flash', variant: 'medium' },
+    fixer: { model: 'google/gemini-3-flash', variant: 'low' },
   },
   openai: {
-    orchestrator: { model: "openai/gpt-5.2-codex" },
-    oracle: { model: "openai/gpt-5.2-codex", variant: "high" },
-    librarian: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
-    explorer: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
-    designer: { model: "openai/gpt-5.1-codex-mini", variant: "medium" },
-    fixer: { model: "openai/gpt-5.1-codex-mini", variant: "low" },
+    orchestrator: { model: 'openai/gpt-5.2-codex' },
+    oracle: { model: 'openai/gpt-5.2-codex', variant: 'high' },
+    librarian: { model: 'openai/gpt-5.1-codex-mini', variant: 'low' },
+    explorer: { model: 'openai/gpt-5.1-codex-mini', variant: 'low' },
+    designer: { model: 'openai/gpt-5.1-codex-mini', variant: 'medium' },
+    fixer: { model: 'openai/gpt-5.1-codex-mini', variant: 'low' },
   },
-  "zen-free": {
-    orchestrator: { model: "opencode/glm-4.7-free" },
-    oracle: { model: "opencode/glm-4.7-free", variant: "high" },
-    librarian: { model: "opencode/grok-code", variant: "low" },
-    explorer: { model: "opencode/grok-code", variant: "low" },
-    designer: { model: "opencode/grok-code", variant: "medium" },
-    fixer: { model: "opencode/grok-code", variant: "low" },
+  'zen-free': {
+    orchestrator: { model: 'opencode/glm-4.7-free' },
+    oracle: { model: 'opencode/glm-4.7-free', variant: 'high' },
+    librarian: { model: 'opencode/grok-code', variant: 'low' },
+    explorer: { model: 'opencode/grok-code', variant: 'low' },
+    designer: { model: 'opencode/grok-code', variant: 'medium' },
+    fixer: { model: 'opencode/grok-code', variant: 'low' },
   },
 } as const;
 
-export function generateLiteConfig(installConfig: InstallConfig): Record<string, unknown> {
+export function generateLiteConfig(
+  installConfig: InstallConfig,
+): Record<string, unknown> {
   // Determine base provider
   const baseProvider = installConfig.hasAntigravity
-    ? "antigravity"
+    ? 'antigravity'
     : installConfig.hasOpenAI
-      ? "openai"
-      : "zen-free";
+      ? 'openai'
+      : 'zen-free';
 
   const config: Record<string, unknown> = {
     preset: baseProvider,
@@ -80,43 +82,53 @@ export function generateLiteConfig(installConfig: InstallConfig): Record<string,
 
   // Generate all presets
   for (const [providerName, models] of Object.entries(MODEL_MAPPINGS)) {
-    const agents: Record<string, { model: string; variant?: string; skills: string[] }> = Object.fromEntries(
+    const agents: Record<
+      string,
+      { model: string; variant?: string; skills: string[] }
+    > = Object.fromEntries(
       Object.entries(models).map(([k, v]) => [
         k,
         {
           model: v.model,
           variant: v.variant,
-          skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
+          skills:
+            DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
         },
-      ])
+      ]),
     );
     (config.presets as Record<string, unknown>)[providerName] = agents;
   }
 
   // Always add antigravity-openai preset
-  const mixedAgents: Record<string, { model: string; variant?: string }> = { ...MODEL_MAPPINGS.antigravity };
-  mixedAgents.oracle = { model: "openai/gpt-5.2-codex", variant: "high" };
-  const agents: Record<string, { model: string; variant?: string; skills: string[] }> = Object.fromEntries(
+  const mixedAgents: Record<string, { model: string; variant?: string }> = {
+    ...MODEL_MAPPINGS.antigravity,
+  };
+  mixedAgents.oracle = { model: 'openai/gpt-5.2-codex', variant: 'high' };
+  const agents: Record<
+    string,
+    { model: string; variant?: string; skills: string[] }
+  > = Object.fromEntries(
     Object.entries(mixedAgents).map(([k, v]) => [
       k,
       {
         model: v.model,
         variant: v.variant,
-        skills: DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
+        skills:
+          DEFAULT_AGENT_SKILLS[k as keyof typeof DEFAULT_AGENT_SKILLS] ?? [],
       },
-    ])
+    ]),
   );
-  (config.presets as Record<string, unknown>)["antigravity-openai"] = agents;
+  (config.presets as Record<string, unknown>)['antigravity-openai'] = agents;
 
   // Set default preset based on user choice
   if (installConfig.hasAntigravity && installConfig.hasOpenAI) {
-    config.preset = "antigravity-openai";
+    config.preset = 'antigravity-openai';
   }
 
   if (installConfig.hasTmux) {
     config.tmux = {
       enabled: true,
-      layout: "main-vertical",
+      layout: 'main-vertical',
       main_pane_size: 60,
     };
   }

+ 40 - 35
src/cli/system.test.ts

@@ -1,61 +1,66 @@
 /// <reference types="bun-types" />
 
-import { describe, expect, test, mock, spyOn } from "bun:test"
-import { isOpenCodeInstalled, isTmuxInstalled, getOpenCodeVersion, fetchLatestVersion } from "./system"
+import { describe, expect, mock, test } from 'bun:test';
+import {
+  fetchLatestVersion,
+  getOpenCodeVersion,
+  isOpenCodeInstalled,
+  isTmuxInstalled,
+} from './system';
 
-describe("system", () => {
-  test("isOpenCodeInstalled returns boolean", async () => {
+describe('system', () => {
+  test('isOpenCodeInstalled returns boolean', async () => {
     // We don't necessarily want to depend on the host system
     // but for a basic test we can just check it returns a boolean
-    const result = await isOpenCodeInstalled()
-    expect(typeof result).toBe("boolean")
-  })
+    const result = await isOpenCodeInstalled();
+    expect(typeof result).toBe('boolean');
+  });
 
-  test("isTmuxInstalled returns boolean", async () => {
-    const result = await isTmuxInstalled()
-    expect(typeof result).toBe("boolean")
-  })
+  test('isTmuxInstalled returns boolean', async () => {
+    const result = await isTmuxInstalled();
+    expect(typeof result).toBe('boolean');
+  });
 
-  test("fetchLatestVersion returns version string or null", async () => {
+  test('fetchLatestVersion returns version string or null', async () => {
     // Mock global fetch
-    const originalFetch = globalThis.fetch
+    const originalFetch = globalThis.fetch;
     globalThis.fetch = mock(async () => {
       return {
         ok: true,
-        json: async () => ({ version: "1.2.3" })
-      }
-    }) as any
+        json: async () => ({ version: '1.2.3' }),
+      };
+    }) as any;
 
     try {
-      const version = await fetchLatestVersion("any-package")
-      expect(version).toBe("1.2.3")
+      const version = await fetchLatestVersion('any-package');
+      expect(version).toBe('1.2.3');
     } finally {
-      globalThis.fetch = originalFetch
+      globalThis.fetch = originalFetch;
     }
-  })
+  });
 
-  test("fetchLatestVersion returns null on error", async () => {
-    const originalFetch = globalThis.fetch
+  test('fetchLatestVersion returns null on error', async () => {
+    const originalFetch = globalThis.fetch;
     try {
       globalThis.fetch = mock(async () => {
         return {
-          ok: false
-        }
-      }) as any
+          ok: false,
+        };
+      }) as any;
 
-      const version = await fetchLatestVersion("any-package")
-      expect(version).toBeNull()
+      const version = await fetchLatestVersion('any-package');
+      expect(version).toBeNull();
     } finally {
-      globalThis.fetch = originalFetch
+      globalThis.fetch = originalFetch;
     }
-  })
+  });
 
-  test("getOpenCodeVersion returns string or null", async () => {
-    const version = await getOpenCodeVersion()
+  test('getOpenCodeVersion returns string or null', async () => {
+    const version = await getOpenCodeVersion();
     if (version !== null) {
-      expect(typeof version).toBe("string")
+      expect(typeof version).toBe('string');
     } else {
-      expect(version).toBeNull()
+      expect(version).toBeNull();
     }
-  })
-})
+  });
+});

+ 30 - 28
src/cli/system.ts

@@ -1,50 +1,52 @@
 export async function isOpenCodeInstalled(): Promise<boolean> {
   try {
-    const proc = Bun.spawn(["opencode", "--version"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    await proc.exited
-    return proc.exitCode === 0
+    const proc = Bun.spawn(['opencode', '--version'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    await proc.exited;
+    return proc.exitCode === 0;
   } catch {
-    return false
+    return false;
   }
 }
 
 export async function isTmuxInstalled(): Promise<boolean> {
   try {
-    const proc = Bun.spawn(["tmux", "-V"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    await proc.exited
-    return proc.exitCode === 0
+    const proc = Bun.spawn(['tmux', '-V'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    await proc.exited;
+    return proc.exitCode === 0;
   } catch {
-    return false
+    return false;
   }
 }
 
 export async function getOpenCodeVersion(): Promise<string | null> {
   try {
-    const proc = Bun.spawn(["opencode", "--version"], {
-      stdout: "pipe",
-      stderr: "pipe",
-    })
-    const output = await new Response(proc.stdout).text()
-    await proc.exited
-    return proc.exitCode === 0 ? output.trim() : null
+    const proc = Bun.spawn(['opencode', '--version'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const output = await new Response(proc.stdout).text();
+    await proc.exited;
+    return proc.exitCode === 0 ? output.trim() : null;
   } catch {
-    return null
+    return null;
   }
 }
 
-export async function fetchLatestVersion(packageName: string): Promise<string | null> {
+export async function fetchLatestVersion(
+  packageName: string,
+): Promise<string | null> {
   try {
-    const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
-    if (!res.ok) return null
-    const data = (await res.json()) as { version: string }
-    return data.version
+    const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`);
+    if (!res.ok) return null;
+    const data = (await res.json()) as { version: string };
+    return data.version;
   } catch {
-    return null
+    return null;
   }
 }

+ 21 - 21
src/cli/types.ts

@@ -1,36 +1,36 @@
-export type BooleanArg = "yes" | "no"
+export type BooleanArg = 'yes' | 'no';
 
 export interface InstallArgs {
-  tui: boolean
-  antigravity?: BooleanArg
-  openai?: BooleanArg
-  tmux?: BooleanArg
+  tui: boolean;
+  antigravity?: BooleanArg;
+  openai?: BooleanArg;
+  tmux?: BooleanArg;
 }
 
 export interface OpenCodeConfig {
-  plugin?: string[]
-  provider?: Record<string, unknown>
-  agent?: Record<string, unknown>
-  [key: string]: unknown
+  plugin?: string[];
+  provider?: Record<string, unknown>;
+  agent?: Record<string, unknown>;
+  [key: string]: unknown;
 }
 
 export interface InstallConfig {
-  hasAntigravity: boolean
-  hasOpenAI: boolean
-  hasOpencodeZen: boolean
-  hasTmux: boolean
+  hasAntigravity: boolean;
+  hasOpenAI: boolean;
+  hasOpencodeZen: boolean;
+  hasTmux: boolean;
 }
 
 export interface ConfigMergeResult {
-  success: boolean
-  configPath: string
-  error?: string
+  success: boolean;
+  configPath: string;
+  error?: string;
 }
 
 export interface DetectedConfig {
-  isInstalled: boolean
-  hasAntigravity: boolean
-  hasOpenAI: boolean
-  hasOpencodeZen: boolean
-  hasTmux: boolean
+  isInstalled: boolean;
+  hasAntigravity: boolean;
+  hasOpenAI: boolean;
+  hasOpencodeZen: boolean;
+  hasTmux: boolean;
 }

+ 13 - 13
src/config/constants.ts

@@ -1,27 +1,27 @@
 // Agent names
 export const SUBAGENT_NAMES = [
-    "explorer",
-    "librarian",
-    "oracle",
-    "designer",
-    "fixer",
+  'explorer',
+  'librarian',
+  'oracle',
+  'designer',
+  'fixer',
 ] as const;
 
-export const ORCHESTRATOR_NAME = "orchestrator" as const;
+export const ORCHESTRATOR_NAME = 'orchestrator' as const;
 
 export const ALL_AGENT_NAMES = [ORCHESTRATOR_NAME, ...SUBAGENT_NAMES] as const;
 
 // Agent name type (for use in DEFAULT_MODELS)
-export type AgentName = typeof ALL_AGENT_NAMES[number];
+export type AgentName = (typeof ALL_AGENT_NAMES)[number];
 
 // Default models for each agent
 export const DEFAULT_MODELS: Record<AgentName, string> = {
-    orchestrator: "google/claude-opus-4-5-thinking",
-    oracle: "openai/gpt-5.2-codex",
-    librarian: "google/gemini-3-flash",
-    explorer: "google/gemini-3-flash",
-    designer: "google/gemini-3-flash",
-    fixer: "google/gemini-3-flash",
+  orchestrator: 'google/claude-opus-4-5-thinking',
+  oracle: 'openai/gpt-5.2-codex',
+  librarian: 'google/gemini-3-flash',
+  explorer: 'google/gemini-3-flash',
+  designer: 'google/gemini-3-flash',
+  fixer: 'google/gemini-3-flash',
 };
 
 // Polling configuration

+ 3 - 3
src/config/index.ts

@@ -1,3 +1,3 @@
-export * from "./schema";
-export * from "./constants";
-export { loadPluginConfig } from "./loader";
+export * from './constants';
+export { loadAgentPrompt, loadPluginConfig } from './loader';
+export * from './schema';

+ 510 - 407
src/config/loader.test.ts

@@ -1,566 +1,669 @@
-import { describe, expect, test, beforeEach, afterEach, mock, spyOn } from "bun:test"
-import * as fs from "fs"
-import * as path from "path"
-import * as os from "os"
-import { loadPluginConfig } from "./loader"
+import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { loadAgentPrompt, loadPluginConfig } from './loader';
 
 // Test deepMerge indirectly through loadPluginConfig behavior
 // since deepMerge is not exported
 
-describe("loadPluginConfig", () => {
-  let tempDir: string
-  let userConfigDir: string
-  let originalEnv: typeof process.env
+describe('loadPluginConfig', () => {
+  let tempDir: string;
+  let userConfigDir: string;
+  let originalEnv: typeof process.env;
 
   beforeEach(() => {
-    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "loader-test-"))
-    userConfigDir = path.join(tempDir, "user-config")
-    originalEnv = { ...process.env }
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'loader-test-'));
+    userConfigDir = path.join(tempDir, 'user-config');
+    originalEnv = { ...process.env };
     // Isolate from real user config
-    process.env.XDG_CONFIG_HOME = userConfigDir
-  })
+    process.env.XDG_CONFIG_HOME = userConfigDir;
+  });
 
   afterEach(() => {
-    fs.rmSync(tempDir, { recursive: true, force: true })
-    process.env = originalEnv
-  })
-
-  test("returns empty config when no config files exist", () => {
-    const projectDir = path.join(tempDir, "project")
-    fs.mkdirSync(projectDir, { recursive: true })
-    const config = loadPluginConfig(projectDir)
-    expect(config).toEqual({})
-  })
-
-  test("loads project config from .opencode directory", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('returns empty config when no config files exist', () => {
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+    const config = loadPluginConfig(projectDir);
+    expect(config).toEqual({});
+  });
+
+  test('loads project config from .opencode directory', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         agents: {
-          oracle: { model: "test/model" },
+          oracle: { model: 'test/model' },
         },
-      })
-    )
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("test/model")
-  })
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('test/model');
+  });
 
-  test("ignores invalid config (schema violation or malformed JSON)", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+  test('ignores invalid config (schema violation or malformed JSON)', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
 
     // Test 1: Invalid temperature (out of range)
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
-      JSON.stringify({ agents: { oracle: { temperature: 5 } } })
-    )
-    expect(loadPluginConfig(projectDir)).toEqual({})
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({ agents: { oracle: { temperature: 5 } } }),
+    );
+    expect(loadPluginConfig(projectDir)).toEqual({});
 
     // Test 2: Malformed JSON
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
-      "{ invalid json }"
-    )
-    expect(loadPluginConfig(projectDir)).toEqual({})
-  })
-})
-
-describe("deepMerge behavior", () => {
-  let tempDir: string
-  let userConfigDir: string
-  let originalEnv: typeof process.env
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      '{ invalid json }',
+    );
+    expect(loadPluginConfig(projectDir)).toEqual({});
+  });
+});
+
+describe('deepMerge behavior', () => {
+  let tempDir: string;
+  let userConfigDir: string;
+  let originalEnv: typeof process.env;
 
   beforeEach(() => {
-    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "merge-test-"))
-    userConfigDir = path.join(tempDir, "user-config")
-    originalEnv = { ...process.env }
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'merge-test-'));
+    userConfigDir = path.join(tempDir, 'user-config');
+    originalEnv = { ...process.env };
 
     // Set XDG_CONFIG_HOME to control user config location
-    process.env.XDG_CONFIG_HOME = userConfigDir
-  })
+    process.env.XDG_CONFIG_HOME = userConfigDir;
+  });
 
   afterEach(() => {
-    fs.rmSync(tempDir, { recursive: true, force: true })
-    process.env = originalEnv
-  })
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
 
-  test("merges nested agent configs from user and project", () => {
+  test('merges nested agent configs from user and project', () => {
     // Create user config
-    const userOpencodeDir = path.join(userConfigDir, "opencode")
-    fs.mkdirSync(userOpencodeDir, { recursive: true })
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
     fs.writeFileSync(
-      path.join(userOpencodeDir, "oh-my-opencode-slim.json"),
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         agents: {
-          oracle: { model: "user/oracle-model", temperature: 0.5 },
-          explorer: { model: "user/explorer-model" },
+          oracle: { model: 'user/oracle-model', temperature: 0.5 },
+          explorer: { model: 'user/explorer-model' },
         },
-      })
-    )
+      }),
+    );
 
     // Create project config (should override/merge with user)
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         agents: {
           oracle: { temperature: 0.8 }, // Override temperature only
-          designer: { model: "project/designer-model" }, // Add new agent
+          designer: { model: 'project/designer-model' }, // Add new agent
         },
-      })
-    )
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
+    const config = loadPluginConfig(projectDir);
 
     // oracle: model from user, temperature from project
-    expect(config.agents?.oracle?.model).toBe("user/oracle-model")
-    expect(config.agents?.oracle?.temperature).toBe(0.8)
+    expect(config.agents?.oracle?.model).toBe('user/oracle-model');
+    expect(config.agents?.oracle?.temperature).toBe(0.8);
 
     // explorer: from user only
-    expect(config.agents?.explorer?.model).toBe("user/explorer-model")
+    expect(config.agents?.explorer?.model).toBe('user/explorer-model');
 
     // designer: from project only
-    expect(config.agents?.designer?.model).toBe("project/designer-model")
-  })
+    expect(config.agents?.designer?.model).toBe('project/designer-model');
+  });
 
-  test("merges nested tmux configs", () => {
-    const userOpencodeDir = path.join(userConfigDir, "opencode")
-    fs.mkdirSync(userOpencodeDir, { recursive: true })
+  test('merges nested tmux configs', () => {
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
     fs.writeFileSync(
-      path.join(userOpencodeDir, "oh-my-opencode-slim.json"),
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         tmux: {
           enabled: true,
-          layout: "main-vertical",
+          layout: 'main-vertical',
           main_pane_size: 60,
         },
-      })
-    )
+      }),
+    );
 
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         tmux: {
           enabled: false, // Override enabled
-          layout: "tiled", // Override layout
+          layout: 'tiled', // Override layout
         },
-      })
-    )
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
+    const config = loadPluginConfig(projectDir);
 
-    expect(config.tmux?.enabled).toBe(false) // From project (override)
-    expect(config.tmux?.layout).toBe("tiled") // From project
-    expect(config.tmux?.main_pane_size).toBe(60) // From user (preserved)
-  })
+    expect(config.tmux?.enabled).toBe(false); // From project (override)
+    expect(config.tmux?.layout).toBe('tiled'); // From project
+    expect(config.tmux?.main_pane_size).toBe(60); // From user (preserved)
+  });
 
   test("preserves user tmux.enabled when project doesn't specify", () => {
-    const userOpencodeDir = path.join(userConfigDir, "opencode")
-    fs.mkdirSync(userOpencodeDir, { recursive: true })
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
     fs.writeFileSync(
-      path.join(userOpencodeDir, "oh-my-opencode-slim.json"),
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         tmux: {
           enabled: true,
-          layout: "main-vertical",
+          layout: 'main-vertical',
         },
-      })
-    )
+      }),
+    );
 
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        agents: { oracle: { model: "test" } }, // No tmux override
-      })
-    )
+        agents: { oracle: { model: 'test' } }, // No tmux override
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
+    const config = loadPluginConfig(projectDir);
 
-    expect(config.tmux?.enabled).toBe(true) // Preserved from user
-    expect(config.tmux?.layout).toBe("main-vertical") // Preserved from user
-  })
+    expect(config.tmux?.enabled).toBe(true); // Preserved from user
+    expect(config.tmux?.layout).toBe('main-vertical'); // Preserved from user
+  });
 
-
-
-  test("project config overrides top-level arrays", () => {
-    const userOpencodeDir = path.join(userConfigDir, "opencode")
-    fs.mkdirSync(userOpencodeDir, { recursive: true })
+  test('project config overrides top-level arrays', () => {
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
     fs.writeFileSync(
-      path.join(userOpencodeDir, "oh-my-opencode-slim.json"),
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        disabled_mcps: ["websearch"],
-      })
-    )
+        disabled_mcps: ['websearch'],
+      }),
+    );
 
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        disabled_mcps: ["context7"],
-      })
-    )
+        disabled_mcps: ['context7'],
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
+    const config = loadPluginConfig(projectDir);
 
     // disabled_mcps should be from project (overwrites, not merges)
-    expect(config.disabled_mcps).toEqual(["context7"])
-  })
+    expect(config.disabled_mcps).toEqual(['context7']);
+  });
 
-  test("handles missing user config gracefully", () => {
+  test('handles missing user config gracefully', () => {
     // Don't create user config, only project
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         agents: {
-          oracle: { model: "project/model" },
+          oracle: { model: 'project/model' },
         },
-      })
-    )
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("project/model")
-  })
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('project/model');
+  });
 
-  test("handles missing project config gracefully", () => {
-    const userOpencodeDir = path.join(userConfigDir, "opencode")
-    fs.mkdirSync(userOpencodeDir, { recursive: true })
+  test('handles missing project config gracefully', () => {
+    const userOpencodeDir = path.join(userConfigDir, 'opencode');
+    fs.mkdirSync(userOpencodeDir, { recursive: true });
     fs.writeFileSync(
-      path.join(userOpencodeDir, "oh-my-opencode-slim.json"),
+      path.join(userOpencodeDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         agents: {
-          oracle: { model: "user/model" },
+          oracle: { model: 'user/model' },
         },
-      })
-    )
+      }),
+    );
 
     // No project config
-    const projectDir = path.join(tempDir, "project")
-    fs.mkdirSync(projectDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
 
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("user/model")
-  })
-})
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('user/model');
+  });
+});
 
-describe("preset resolution", () => {
-  let tempDir: string
-  let originalEnv: typeof process.env
+describe('preset resolution', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
 
   beforeEach(() => {
-    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "preset-test-"))
-    originalEnv = { ...process.env }
-    process.env.XDG_CONFIG_HOME = path.join(tempDir, "user-config")
-  })
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
+    originalEnv = { ...process.env };
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
+  });
 
   afterEach(() => {
-    fs.rmSync(tempDir, { recursive: true, force: true })
-    process.env = originalEnv
-  })
-
-  test("backward compatibility: config with only agents works unchanged", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('backward compatibility: config with only agents works unchanged', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        agents: { oracle: { model: "direct-model" } }
-      })
-    )
+        agents: { oracle: { model: 'direct-model' } },
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("direct-model")
-    expect(config.preset).toBeUndefined()
-  })
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('direct-model');
+    expect(config.preset).toBeUndefined();
+  });
 
   test("preset applied: preset + presets returns preset's agents", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "fast",
+        preset: 'fast',
         presets: {
-          fast: { oracle: { model: "fast-model" } }
-        }
-      })
-    )
+          fast: { oracle: { model: 'fast-model' } },
+        },
+      }),
+    );
 
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("fast-model")
-  })
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('fast-model');
+  });
 
-  test("root agents override preset agents", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+  test('root agents override preset agents', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "fast",
+        preset: 'fast',
         presets: {
-          fast: { 
-            oracle: { model: "fast-model", temperature: 0.1 },
-            explorer: { model: "explorer-model" }
-          }
+          fast: {
+            oracle: { model: 'fast-model', temperature: 0.1 },
+            explorer: { model: 'explorer-model' },
+          },
         },
         agents: {
-          oracle: { temperature: 0.9 } // Should override preset temperature
-        }
-      })
-    )
-
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("fast-model")
-    expect(config.agents?.oracle?.temperature).toBe(0.9)
-    expect(config.agents?.explorer?.model).toBe("explorer-model")
-  })
-
-  test("missing preset: preset set but not in presets -> returns empty/root agents", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+          oracle: { temperature: 0.9 }, // Should override preset temperature
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('fast-model');
+    expect(config.agents?.oracle?.temperature).toBe(0.9);
+    expect(config.agents?.explorer?.model).toBe('explorer-model');
+  });
+
+  test('missing preset: preset set but not in presets -> returns empty/root agents', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "nonexistent",
+        preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: "other" } }
+          other: { oracle: { model: 'other' } },
         },
-        agents: { oracle: { model: "root" } }
-      })
-    )
-
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("root")
-  })
-
-  test("preset only: no root agents, just preset works", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+        agents: { oracle: { model: 'root' } },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('root');
+  });
+
+  test('preset only: no root agents, just preset works', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "dev",
+        preset: 'dev',
         presets: {
-          dev: { oracle: { model: "dev-model" } }
-        }
-      })
-    )
-
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("dev-model")
-  })
-
-  test("invalid preset shape: bad agent config in preset fails schema validation", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
-    
+          dev: { oracle: { model: 'dev-model' } },
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('dev-model');
+  });
+
+  test('invalid preset shape: bad agent config in preset fails schema validation', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+
     // preset agents with invalid temperature
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "invalid",
+        preset: 'invalid',
         presets: {
-          invalid: { oracle: { temperature: 5 } }
-        }
-      })
-    )
+          invalid: { oracle: { temperature: 5 } },
+        },
+      }),
+    );
 
     // Should return empty config due to validation failure
-    expect(loadPluginConfig(projectDir)).toEqual({})
-  })
+    expect(loadPluginConfig(projectDir)).toEqual({});
+  });
 
-  test("nonexistent preset from config warns and falls back to root agents", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+  test('nonexistent preset from config warns and falls back to root agents', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "nonexistent",
+        preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: "other" } }
+          other: { oracle: { model: 'other' } },
         },
-        agents: { oracle: { model: "root" } }
-      })
-    )
-
-    const consoleWarnSpy = spyOn(console, "warn")
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents?.oracle?.model).toBe("root")
-    expect(consoleWarnSpy).toHaveBeenCalled()
-    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string
-    expect(warningMessage).toContain('Preset "nonexistent" not found')
-    expect(warningMessage).toContain('Available presets: other')
-  })
-
-  test("nonexistent preset with no root agents returns empty agents", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+        agents: { oracle: { model: 'root' } },
+      }),
+    );
+
+    const consoleWarnSpy = spyOn(console, 'warn');
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('root');
+    expect(consoleWarnSpy).toHaveBeenCalled();
+    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
+    expect(warningMessage).toContain('Preset "nonexistent" not found');
+    expect(warningMessage).toContain('Available presets: other');
+  });
+
+  test('nonexistent preset with no root agents returns empty agents', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "nonexistent",
+        preset: 'nonexistent',
         presets: {
-          other: { oracle: { model: "other" } }
-        }
-      })
-    )
-
-    const consoleWarnSpy = spyOn(console, "warn")
-    const config = loadPluginConfig(projectDir)
-    expect(config.agents).toBeUndefined()
-    expect(consoleWarnSpy).toHaveBeenCalled()
-    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string
-    expect(warningMessage).toContain('Preset "nonexistent" not found')
-  })
-})
-
-describe("environment variable preset override", () => {
-  let tempDir: string
-  let originalEnv: typeof process.env
+          other: { oracle: { model: 'other' } },
+        },
+      }),
+    );
+
+    const consoleWarnSpy = spyOn(console, 'warn');
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents).toBeUndefined();
+    expect(consoleWarnSpy).toHaveBeenCalled();
+    const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
+    expect(warningMessage).toContain('Preset "nonexistent" not found');
+  });
+});
+
+describe('environment variable preset override', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
 
   beforeEach(() => {
-    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "env-preset-test-"))
-    originalEnv = { ...process.env }
-    process.env.XDG_CONFIG_HOME = path.join(tempDir, "user-config")
-  })
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
+    originalEnv = { ...process.env };
+    process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
+  });
 
   afterEach(() => {
-    fs.rmSync(tempDir, { recursive: true, force: true })
-    process.env = originalEnv
-  })
-
-  test("Env var overrides preset from config file", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('Env var overrides preset from config file', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "config-preset",
+        preset: 'config-preset',
         presets: {
-          "config-preset": { oracle: { model: "config-model" } },
-          "env-preset": { oracle: { model: "env-model" } }
-        }
-      })
-    )
-
-    process.env.OH_MY_OPENCODE_SLIM_PRESET = "env-preset"
-    const config = loadPluginConfig(projectDir)
-    expect(config.preset).toBe("env-preset")
-    expect(config.agents?.oracle?.model).toBe("env-model")
-  })
-
-  test("Env var works when config has no preset", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+          'config-preset': { oracle: { model: 'config-model' } },
+          'env-preset': { oracle: { model: 'env-model' } },
+        },
+      }),
+    );
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('env-preset');
+    expect(config.agents?.oracle?.model).toBe('env-model');
+  });
+
+  test('Env var works when config has no preset', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
         presets: {
-          "env-preset": { oracle: { model: "env-model" } }
-        }
-      })
-    )
-
-    process.env.OH_MY_OPENCODE_SLIM_PRESET = "env-preset"
-    const config = loadPluginConfig(projectDir)
-    expect(config.preset).toBe("env-preset")
-    expect(config.agents?.oracle?.model).toBe("env-model")
-  })
-
-  test("Env var is ignored if empty string", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+          'env-preset': { oracle: { model: 'env-model' } },
+        },
+      }),
+    );
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = 'env-preset';
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('env-preset');
+    expect(config.agents?.oracle?.model).toBe('env-model');
+  });
+
+  test('Env var is ignored if empty string', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "config-preset",
+        preset: 'config-preset',
         presets: {
-          "config-preset": { oracle: { model: "config-model" } }
-        }
-      })
-    )
-
-    process.env.OH_MY_OPENCODE_SLIM_PRESET = ""
-    const config = loadPluginConfig(projectDir)
-    expect(config.preset).toBe("config-preset")
-    expect(config.agents?.oracle?.model).toBe("config-model")
-  })
-
-  test("Env var is ignored if undefined", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+          'config-preset': { oracle: { model: 'config-model' } },
+        },
+      }),
+    );
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = '';
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('config-preset');
+    expect(config.agents?.oracle?.model).toBe('config-model');
+  });
+
+  test('Env var is ignored if undefined', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "config-preset",
+        preset: 'config-preset',
         presets: {
-          "config-preset": { oracle: { model: "config-model" } }
-        }
-      })
-    )
-
-    delete process.env.OH_MY_OPENCODE_SLIM_PRESET
-    const config = loadPluginConfig(projectDir)
-    expect(config.preset).toBe("config-preset")
-    expect(config.agents?.oracle?.model).toBe("config-model")
-  })
-
-  test("Env var with nonexistent preset warns and falls back", () => {
-    const projectDir = path.join(tempDir, "project")
-    const projectConfigDir = path.join(projectDir, ".opencode")
-    fs.mkdirSync(projectConfigDir, { recursive: true })
+          'config-preset': { oracle: { model: 'config-model' } },
+        },
+      }),
+    );
+
+    delete process.env.OH_MY_OPENCODE_SLIM_PRESET;
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('config-preset');
+    expect(config.agents?.oracle?.model).toBe('config-model');
+  });
+
+  test('Env var with nonexistent preset warns and falls back', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
     fs.writeFileSync(
-      path.join(projectConfigDir, "oh-my-opencode-slim.json"),
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
       JSON.stringify({
-        preset: "config-preset",
+        preset: 'config-preset',
         presets: {
-          "config-preset": { oracle: { model: "config-model" } }
+          'config-preset': { oracle: { model: 'config-model' } },
         },
-        agents: { oracle: { model: "fallback" } }
-      })
-    )
-
-    process.env.OH_MY_OPENCODE_SLIM_PRESET = "typo-preset"
-    const consoleWarnSpy = spyOn(console, "warn")
-    const config = loadPluginConfig(projectDir)
-    expect(config.preset).toBe("typo-preset")
-    expect(config.agents?.oracle?.model).toBe("fallback")
-    expect(consoleWarnSpy).toHaveBeenCalled()
-    const calls = consoleWarnSpy.mock.calls as string[][]
-    const warningMessage = calls.find(call => 
-      call[0]?.includes("typo-preset")
-    )?.[0] || ""
-    expect(warningMessage).toContain('Preset "typo-preset" not found')
-    expect(warningMessage).toContain('environment variable')
-    expect(warningMessage).toContain('config-preset')
-  })
-})
+        agents: { oracle: { model: 'fallback' } },
+      }),
+    );
+
+    process.env.OH_MY_OPENCODE_SLIM_PRESET = 'typo-preset';
+    const consoleWarnSpy = spyOn(console, 'warn');
+    const config = loadPluginConfig(projectDir);
+    expect(config.preset).toBe('typo-preset');
+    expect(config.agents?.oracle?.model).toBe('fallback');
+    expect(consoleWarnSpy).toHaveBeenCalled();
+    const calls = consoleWarnSpy.mock.calls as string[][];
+    const warningMessage =
+      calls.find((call) => call[0]?.includes('typo-preset'))?.[0] || '';
+    expect(warningMessage).toContain('Preset "typo-preset" not found');
+    expect(warningMessage).toContain('environment variable');
+    expect(warningMessage).toContain('config-preset');
+  });
+});
+
+describe('loadAgentPrompt', () => {
+  let tempDir: string;
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
+    originalEnv = { ...process.env };
+    process.env.XDG_CONFIG_HOME = tempDir;
+  });
+
+  afterEach(() => {
+    fs.rmSync(tempDir, { recursive: true, force: true });
+    process.env = originalEnv;
+  });
+
+  test('returns empty object when no prompt files exist', () => {
+    const result = loadAgentPrompt('oracle');
+    expect(result).toEqual({});
+  });
+
+  test('loads replacement prompt from {agent}.md', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
+
+    const result = loadAgentPrompt('oracle');
+    expect(result.prompt).toBe('replacement prompt');
+    expect(result.appendPrompt).toBeUndefined();
+  });
+
+  test('loads append prompt from {agent}_append.md', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle_append.md'),
+      'append prompt',
+    );
+
+    const result = loadAgentPrompt('oracle');
+    expect(result.prompt).toBeUndefined();
+    expect(result.appendPrompt).toBe('append prompt');
+  });
+
+  test('loads both replacement and append prompts', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(path.join(promptsDir, 'oracle.md'), 'replacement prompt');
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle_append.md'),
+      'append prompt',
+    );
+
+    const result = loadAgentPrompt('oracle');
+    expect(result.prompt).toBe('replacement prompt');
+    expect(result.appendPrompt).toBe('append prompt');
+  });
+
+  test('handles file read errors gracefully', () => {
+    const promptsDir = path.join(tempDir, 'opencode', 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    const promptPath = path.join(promptsDir, 'error-agent.md');
+    fs.writeFileSync(promptPath, 'content');
+
+    const consoleWarnSpy = spyOn(console, 'warn');
+
+    // Use a unique agent name and check for it specifically
+    const originalReadFileSync = fs.readFileSync;
+    const readSpy = spyOn(fs, 'readFileSync').mockImplementation(
+      (p: any, o: any) => {
+        if (typeof p === 'string' && p.includes('error-agent.md')) {
+          throw new Error('Read error');
+        }
+        return originalReadFileSync(p, o);
+      },
+    );
+
+    try {
+      const result = loadAgentPrompt('error-agent');
+      expect(result.prompt).toBeUndefined();
+
+      const warningFound = consoleWarnSpy.mock.calls.some((call) =>
+        (call[0] as string).includes('Error reading prompt file'),
+      );
+      expect(warningFound).toBe(true);
+    } finally {
+      readSpy.mockRestore();
+    }
+  });
+
+  test('works with XDG_CONFIG_HOME environment variable', () => {
+    const customConfigHome = path.join(tempDir, 'custom-xdg');
+    process.env.XDG_CONFIG_HOME = customConfigHome;
+
+    const promptsDir = path.join(
+      customConfigHome,
+      'opencode',
+      'oh-my-opencode-slim',
+    );
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(path.join(promptsDir, 'xdg-agent.md'), 'xdg prompt');
+
+    const result = loadAgentPrompt('xdg-agent');
+    expect(result.prompt).toBe('xdg prompt');
+  });
+});

+ 92 - 26
src/config/loader.ts

@@ -1,31 +1,32 @@
-import * as fs from "fs";
-import * as path from "path";
-import * as os from "os";
-import { PluginConfigSchema, type PluginConfig } from "./schema";
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { type PluginConfig, PluginConfigSchema } from './schema';
 
-const CONFIG_FILENAME = "oh-my-opencode-slim.json";
+const CONFIG_FILENAME = 'oh-my-opencode-slim.json';
+const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 
 /**
  * Get the user's configuration directory following XDG Base Directory specification.
  * Falls back to ~/.config if XDG_CONFIG_HOME is not set.
- * 
+ *
  * @returns The absolute path to the user's config directory
  */
 function getUserConfigDir(): string {
-  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
+  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
 }
 
 /**
  * Load and validate plugin configuration from a specific file path.
  * Returns null if the file doesn't exist, is invalid, or cannot be read.
  * Logs warnings for validation errors and unexpected read errors.
- * 
+ *
  * @param configPath - Absolute path to the config file
  * @returns Validated config object, or null if loading failed
  */
 function loadConfigFromPath(configPath: string): PluginConfig | null {
   try {
-    const content = fs.readFileSync(configPath, "utf-8");
+    const content = fs.readFileSync(configPath, 'utf-8');
     const rawConfig = JSON.parse(content);
     const result = PluginConfigSchema.safeParse(rawConfig);
 
@@ -38,8 +39,15 @@ function loadConfigFromPath(configPath: string): PluginConfig | null {
     return result.data;
   } catch (error) {
     // File doesn't exist or isn't readable - this is expected and fine
-    if (error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code !== 'ENOENT') {
-      console.warn(`[oh-my-opencode-slim] Error reading config from ${configPath}:`, error.message);
+    if (
+      error instanceof Error &&
+      'code' in error &&
+      (error as NodeJS.ErrnoException).code !== 'ENOENT'
+    ) {
+      console.warn(
+        `[oh-my-opencode-slim] Error reading config from ${configPath}:`,
+        error.message,
+      );
     }
     return null;
   }
@@ -48,12 +56,15 @@ function loadConfigFromPath(configPath: string): PluginConfig | null {
 /**
  * Recursively merge two objects, with override values taking precedence.
  * For nested objects, merges recursively. For arrays and primitives, override replaces base.
- * 
+ *
  * @param base - Base object to merge into
  * @param override - Override object whose values take precedence
  * @returns Merged object, or undefined if both inputs are undefined
  */
-function deepMerge<T extends Record<string, unknown>>(base?: T, override?: T): T | undefined {
+function deepMerge<T extends Record<string, unknown>>(
+  base?: T,
+  override?: T,
+): T | undefined {
   if (!base) return override;
   if (!override) return base;
 
@@ -63,13 +74,16 @@ function deepMerge<T extends Record<string, unknown>>(base?: T, override?: T): T
     const overrideVal = override[key];
 
     if (
-      typeof baseVal === "object" && baseVal !== null &&
-      typeof overrideVal === "object" && overrideVal !== null &&
-      !Array.isArray(baseVal) && !Array.isArray(overrideVal)
+      typeof baseVal === 'object' &&
+      baseVal !== null &&
+      typeof overrideVal === 'object' &&
+      overrideVal !== null &&
+      !Array.isArray(baseVal) &&
+      !Array.isArray(overrideVal)
     ) {
       result[key] = deepMerge(
         baseVal as Record<string, unknown>,
-        overrideVal as Record<string, unknown>
+        overrideVal as Record<string, unknown>,
       ) as T[keyof T];
     } else {
       result[key] = overrideVal;
@@ -80,25 +94,25 @@ function deepMerge<T extends Record<string, unknown>>(base?: T, override?: T): T
 
 /**
  * Load plugin configuration from user and project config files, merging them appropriately.
- * 
+ *
  * Configuration is loaded from two locations:
  * 1. User config: ~/.config/opencode/oh-my-opencode-slim.json (or $XDG_CONFIG_HOME)
  * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.json
- * 
+ *
  * Project config takes precedence over user config. Nested objects (agents, tmux) are
  * deep-merged, while top-level arrays are replaced entirely by project config.
- * 
+ *
  * @param directory - Project directory to search for .opencode config
  * @returns Merged plugin configuration (empty object if no configs found)
  */
 export function loadPluginConfig(directory: string): PluginConfig {
   const userConfigPath = path.join(
     getUserConfigDir(),
-    "opencode",
-    CONFIG_FILENAME
+    'opencode',
+    CONFIG_FILENAME,
   );
 
-  const projectConfigPath = path.join(directory, ".opencode", CONFIG_FILENAME);
+  const projectConfigPath = path.join(directory, '.opencode', CONFIG_FILENAME);
 
   let config: PluginConfig = loadConfigFromPath(userConfigPath) ?? {};
 
@@ -126,11 +140,63 @@ export function loadPluginConfig(directory: string): PluginConfig {
       config.agents = deepMerge(preset, config.agents);
     } else {
       // Preset name specified but doesn't exist - warn user
-      const presetSource = envPreset === config.preset ? "environment variable" : "config file";
-      const availablePresets = config.presets ? Object.keys(config.presets).join(", ") : "none";
-      console.warn(`[oh-my-opencode-slim] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`);
+      const presetSource =
+        envPreset === config.preset ? 'environment variable' : 'config file';
+      const availablePresets = config.presets
+        ? Object.keys(config.presets).join(', ')
+        : 'none';
+      console.warn(
+        `[oh-my-opencode-slim] Preset "${config.preset}" not found (from ${presetSource}). Available presets: ${availablePresets}`,
+      );
     }
   }
 
   return config;
 }
+
+/**
+ * Load custom prompt for an agent from the prompts directory.
+ * Checks for {agent}.md (replaces default) and {agent}_append.md (appends to default).
+ *
+ * @param agentName - Name of the agent (e.g., "orchestrator", "explorer")
+ * @returns Object with prompt and/or appendPrompt if files exist
+ */
+export function loadAgentPrompt(agentName: string): {
+  prompt?: string;
+  appendPrompt?: string;
+} {
+  const promptsDir = path.join(
+    getUserConfigDir(),
+    'opencode',
+    PROMPTS_DIR_NAME,
+  );
+  const result: { prompt?: string; appendPrompt?: string } = {};
+
+  // Check for replacement prompt
+  const promptPath = path.join(promptsDir, `${agentName}.md`);
+  if (fs.existsSync(promptPath)) {
+    try {
+      result.prompt = fs.readFileSync(promptPath, 'utf-8');
+    } catch (error) {
+      console.warn(
+        `[oh-my-opencode-slim] Error reading prompt file ${promptPath}:`,
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+  }
+
+  // Check for append prompt
+  const appendPromptPath = path.join(promptsDir, `${agentName}_append.md`);
+  if (fs.existsSync(appendPromptPath)) {
+    try {
+      result.appendPrompt = fs.readFileSync(appendPromptPath, 'utf-8');
+    } catch (error) {
+      console.warn(
+        `[oh-my-opencode-slim] Error reading append prompt file ${appendPromptPath}:`,
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+  }
+
+  return result;
+}

+ 9 - 9
src/config/schema.ts

@@ -1,4 +1,4 @@
-import { z } from "zod";
+import { z } from 'zod';
 
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const AgentOverrideConfigSchema = z.object({
@@ -10,11 +10,11 @@ export const AgentOverrideConfigSchema = z.object({
 
 // Tmux layout options
 export const TmuxLayoutSchema = z.enum([
-  "main-horizontal", // Main pane on top, agents stacked below
-  "main-vertical",   // Main pane on left, agents stacked on right
-  "tiled",           // All panes equal size grid
-  "even-horizontal", // All panes side by side
-  "even-vertical",   // All panes stacked vertically
+  'main-horizontal', // Main pane on top, agents stacked below
+  'main-vertical', // Main pane on left, agents stacked on right
+  'tiled', // All panes equal size grid
+  'even-horizontal', // All panes side by side
+  'even-vertical', // All panes stacked vertically
 ]);
 
 export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>;
@@ -22,7 +22,7 @@ export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>;
 // Tmux integration configuration
 export const TmuxConfigSchema = z.object({
   enabled: z.boolean().default(false),
-  layout: TmuxLayoutSchema.default("main-vertical"),
+  layout: TmuxLayoutSchema.default('main-vertical'),
   main_pane_size: z.number().min(20).max(80).default(60), // percentage for main pane
 });
 
@@ -35,7 +35,7 @@ export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
 export type Preset = z.infer<typeof PresetSchema>;
 
 // MCP names
-export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"]);
+export const McpNameSchema = z.enum(['websearch', 'context7', 'grep_app']);
 export type McpName = z.infer<typeof McpNameSchema>;
 
 // Main plugin config
@@ -50,4 +50,4 @@ export const PluginConfigSchema = z.object({
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;
 
 // Agent names - re-exported from constants for convenience
-export type { AgentName } from "./constants";
+export type { AgentName } from './constants';

+ 57 - 55
src/hooks/auto-update-checker/cache.test.ts

@@ -1,71 +1,73 @@
-import { describe, expect, test, mock, beforeEach } from "bun:test"
-import { invalidatePackage } from "./cache"
-import * as fs from "node:fs"
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import { invalidatePackage } from './cache';
 
 // Mock internal dependencies
-mock.module("./constants", () => ({
-    CACHE_DIR: "/mock/cache",
-    PACKAGE_NAME: "oh-my-opencode-slim"
-}))
+mock.module('./constants', () => ({
+  CACHE_DIR: '/mock/cache',
+  PACKAGE_NAME: 'oh-my-opencode-slim',
+}));
 
-mock.module("../../shared/logger", () => ({
-    log: mock(() => { })
-}))
+mock.module('../../shared/logger', () => ({
+  log: mock(() => {}),
+}));
 
 // Mock fs and path
-mock.module("node:fs", () => ({
-    existsSync: mock(() => false),
-    rmSync: mock(() => { }),
-    readFileSync: mock(() => ""),
-    writeFileSync: mock(() => { }),
-}))
+mock.module('node:fs', () => ({
+  existsSync: mock(() => false),
+  rmSync: mock(() => {}),
+  readFileSync: mock(() => ''),
+  writeFileSync: mock(() => {}),
+}));
 
-mock.module("../../cli/config-manager", () => ({
-    stripJsonComments: (s: string) => s
-}))
+mock.module('../../cli/config-manager', () => ({
+  stripJsonComments: (s: string) => s,
+}));
 
-describe("auto-update-checker/cache", () => {
-    describe("invalidatePackage", () => {
-        test("returns false when nothing to invalidate", () => {
-            const existsMock = fs.existsSync as any
-            existsMock.mockReturnValue(false)
+describe('auto-update-checker/cache', () => {
+  describe('invalidatePackage', () => {
+    test('returns false when nothing to invalidate', () => {
+      const existsMock = fs.existsSync as any;
+      existsMock.mockReturnValue(false);
 
-            const result = invalidatePackage()
-            expect(result).toBe(false)
-        })
+      const result = invalidatePackage();
+      expect(result).toBe(false);
+    });
 
-        test("returns true and removes directory if node_modules path exists", () => {
-            const existsMock = fs.existsSync as any
-            const rmSyncMock = fs.rmSync as any
+    test('returns true and removes directory if node_modules path exists', () => {
+      const existsMock = fs.existsSync as any;
+      const rmSyncMock = fs.rmSync as any;
 
-            existsMock.mockImplementation((p: string) => p.includes("node_modules"))
+      existsMock.mockImplementation((p: string) => p.includes('node_modules'));
 
-            const result = invalidatePackage()
+      const result = invalidatePackage();
 
-            expect(rmSyncMock).toHaveBeenCalled()
-            expect(result).toBe(true)
-        })
+      expect(rmSyncMock).toHaveBeenCalled();
+      expect(result).toBe(true);
+    });
 
-        test("removes dependency from package.json if present", () => {
-            const existsMock = fs.existsSync as any
-            const readMock = fs.readFileSync as any
-            const writeMock = fs.writeFileSync as any
+    test('removes dependency from package.json if present', () => {
+      const existsMock = fs.existsSync as any;
+      const readMock = fs.readFileSync as any;
+      const writeMock = fs.writeFileSync as any;
 
-            existsMock.mockImplementation((p: string) => p.includes("package.json"))
-            readMock.mockReturnValue(JSON.stringify({
-                dependencies: {
-                    "oh-my-opencode-slim": "1.0.0",
-                    "other-pkg": "1.0.0"
-                }
-            }))
+      existsMock.mockImplementation((p: string) => p.includes('package.json'));
+      readMock.mockReturnValue(
+        JSON.stringify({
+          dependencies: {
+            'oh-my-opencode-slim': '1.0.0',
+            'other-pkg': '1.0.0',
+          },
+        }),
+      );
 
-            const result = invalidatePackage()
+      const result = invalidatePackage();
 
-            expect(result).toBe(true)
-            const callArgs = writeMock.mock.calls[0]
-            const savedJson = JSON.parse(callArgs[1])
-            expect(savedJson.dependencies["oh-my-opencode-slim"]).toBeUndefined()
-            expect(savedJson.dependencies["other-pkg"]).toBe("1.0.0")
-        })
-    })
-})
+      expect(result).toBe(true);
+      const callArgs = writeMock.mock.calls[0];
+      const savedJson = JSON.parse(callArgs[1]);
+      expect(savedJson.dependencies['oh-my-opencode-slim']).toBeUndefined();
+      expect(savedJson.dependencies['other-pkg']).toBe('1.0.0');
+    });
+  });
+});

+ 55 - 48
src/hooks/auto-update-checker/cache.ts

@@ -1,16 +1,16 @@
-import * as fs from "node:fs"
-import * as path from "node:path"
-import { CACHE_DIR, PACKAGE_NAME } from "./constants"
-import { log } from "../../utils/logger"
-import { stripJsonComments } from "../../cli/config-manager"
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { stripJsonComments } from '../../cli/config-manager';
+import { log } from '../../utils/logger';
+import { CACHE_DIR, PACKAGE_NAME } from './constants';
 
 interface BunLockfile {
   workspaces?: {
-    ""?: {
-      dependencies?: Record<string, string>
-    }
-  }
-  packages?: Record<string, unknown>
+    ''?: {
+      dependencies?: Record<string, string>;
+    };
+  };
+  packages?: Record<string, unknown>;
 }
 
 /**
@@ -19,42 +19,42 @@ interface BunLockfile {
  * This function handles JSON-based lockfiles gracefully.
  */
 function removeFromBunLock(packageName: string): boolean {
-  const lockPath = path.join(CACHE_DIR, "bun.lock")
-  if (!fs.existsSync(lockPath)) return false
+  const lockPath = path.join(CACHE_DIR, 'bun.lock');
+  if (!fs.existsSync(lockPath)) return false;
 
   try {
-    const content = fs.readFileSync(lockPath, "utf-8")
-    let lock: BunLockfile
+    const content = fs.readFileSync(lockPath, 'utf-8');
+    let lock: BunLockfile;
 
     try {
-      lock = JSON.parse(stripJsonComments(content)) as BunLockfile
+      lock = JSON.parse(stripJsonComments(content)) as BunLockfile;
     } catch {
       // If it's not valid JSON(C), it might be the new Bun text format or binary format.
       // For now, we only support JSON-based lockfile manipulation.
-      return false
+      return false;
     }
 
-    let modified = false
+    let modified = false;
 
-    if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
-      delete lock.workspaces[""].dependencies[packageName]
-      modified = true
+    if (lock.workspaces?.['']?.dependencies?.[packageName]) {
+      delete lock.workspaces[''].dependencies[packageName];
+      modified = true;
     }
 
     if (lock.packages?.[packageName]) {
-      delete lock.packages[packageName]
-      modified = true
+      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}`)
+      fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2));
+      log(`[auto-update-checker] Removed from bun.lock: ${packageName}`);
     }
 
-    return modified
+    return modified;
   } catch (err) {
-    log(`[auto-update-checker] Failed to process bun.lock:`, err)
-    return false
+    log(`[auto-update-checker] Failed to process bun.lock:`, err);
+    return false;
   }
 }
 
@@ -65,44 +65,51 @@ function removeFromBunLock(packageName: string): boolean {
  */
 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")
+    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
+    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
+      fs.rmSync(pkgDir, { recursive: true, force: true });
+      log(`[auto-update-checker] Package removed: ${pkgDir}`);
+      packageRemoved = true;
     }
 
     if (fs.existsSync(pkgJsonPath)) {
       try {
-        const content = fs.readFileSync(pkgJsonPath, "utf-8")
-        const pkgJson = JSON.parse(stripJsonComments(content))
+        const content = fs.readFileSync(pkgJsonPath, 'utf-8');
+        const pkgJson = JSON.parse(stripJsonComments(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
+          delete pkgJson.dependencies[packageName];
+          fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
+          log(
+            `[auto-update-checker] Dependency removed from package.json: ${packageName}`,
+          );
+          dependencyRemoved = true;
         }
       } catch (err) {
-        log(`[auto-update-checker] Failed to update package.json for invalidation:`, err)
+        log(
+          `[auto-update-checker] Failed to update package.json for invalidation:`,
+          err,
+        );
       }
     }
 
-    lockRemoved = removeFromBunLock(packageName)
+    lockRemoved = removeFromBunLock(packageName);
 
     if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
-      log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
-      return false
+      log(
+        `[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`,
+      );
+      return false;
     }
 
-    return true
+    return true;
   } catch (err) {
-    log("[auto-update-checker] Failed to invalidate package:", err)
-    return false
+    log('[auto-update-checker] Failed to invalidate package:', err);
+    return false;
   }
 }

+ 114 - 104
src/hooks/auto-update-checker/checker.test.ts

@@ -1,106 +1,116 @@
-import { describe, expect, test, mock } from "bun:test"
-import { extractChannel, getLocalDevVersion, findPluginEntry } from "./checker"
-import * as fs from "node:fs"
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import { extractChannel, findPluginEntry, getLocalDevVersion } from './checker';
 
 // Mock the dependencies
-mock.module("./constants", () => ({
-    PACKAGE_NAME: "oh-my-opencode-slim",
-    USER_OPENCODE_CONFIG: "/mock/config/opencode.json",
-    USER_OPENCODE_CONFIG_JSONC: "/mock/config/opencode.jsonc",
-    INSTALLED_PACKAGE_JSON: "/mock/cache/node_modules/oh-my-opencode-slim/package.json"
-}))
-
-mock.module("node:fs", () => ({
-    existsSync: mock((p: string) => false),
-    readFileSync: mock((p: string) => ""),
-    statSync: mock((p: string) => ({ isDirectory: () => true })),
-    writeFileSync: mock(() => { }),
-}))
-
-describe("auto-update-checker/checker", () => {
-    describe("extractChannel", () => {
-        test("returns latest for null or empty", () => {
-            expect(extractChannel(null)).toBe("latest")
-            expect(extractChannel("")).toBe("latest")
-        })
-
-        test("returns tag if version starts with non-digit", () => {
-            expect(extractChannel("beta")).toBe("beta")
-            expect(extractChannel("next")).toBe("next")
-        })
-
-        test("extracts channel from prerelease version", () => {
-            expect(extractChannel("1.0.0-alpha.1")).toBe("alpha")
-            expect(extractChannel("2.3.4-beta.5")).toBe("beta")
-            expect(extractChannel("0.1.0-rc.1")).toBe("rc")
-            expect(extractChannel("1.0.0-canary.0")).toBe("canary")
-        })
-
-        test("returns latest for standard versions", () => {
-            expect(extractChannel("1.0.0")).toBe("latest")
-        })
-    })
-
-    describe("getLocalDevVersion", () => {
-        test("returns null if no local dev path in config", () => {
-            // existsSync returns false by default from mock
-            expect(getLocalDevVersion("/test")).toBeNull()
-        })
-
-        test("returns version from local package.json if path exists", () => {
-            const existsMock = fs.existsSync as any
-            const readMock = fs.readFileSync as any
-
-            existsMock.mockImplementation((p: string) => {
-                if (p.includes("opencode.json")) return true
-                if (p.includes("package.json")) return true
-                return false
-            })
-
-            readMock.mockImplementation((p: string) => {
-                if (p.includes("opencode.json")) {
-                    return JSON.stringify({ plugin: ["file:///dev/oh-my-opencode-slim"] })
-                }
-                if (p.includes("package.json")) {
-                    return JSON.stringify({ name: "oh-my-opencode-slim", version: "1.2.3-dev" })
-                }
-                return ""
-            })
-
-            expect(getLocalDevVersion("/test")).toBe("1.2.3-dev")
-        })
-    })
-
-    describe("findPluginEntry", () => {
-        test("detects latest version entry", () => {
-            const existsMock = fs.existsSync as any
-            const readMock = fs.readFileSync as any
-
-            existsMock.mockImplementation((p: string) => p.includes("opencode.json"))
-            readMock.mockImplementation(() => JSON.stringify({
-                plugin: ["oh-my-opencode-slim"]
-            }))
-
-            const entry = findPluginEntry("/test")
-            expect(entry).not.toBeNull()
-            expect(entry?.entry).toBe("oh-my-opencode-slim")
-            expect(entry?.isPinned).toBe(false)
-            expect(entry?.pinnedVersion).toBeNull()
-        })
-
-        test("detects pinned version entry", () => {
-            const existsMock = fs.existsSync as any
-            const readMock = fs.readFileSync as any
-
-            existsMock.mockImplementation((p: string) => p.includes("opencode.json"))
-            readMock.mockImplementation(() => JSON.stringify({
-                plugin: ["oh-my-opencode-slim@1.0.0"]
-            }))
-
-            const entry = findPluginEntry("/test")
-            expect(entry).not.toBeNull()
-            expect(entry?.isPinned).toBe(true)
-            expect(entry?.pinnedVersion).toBe("1.0.0")
-        })
-    })
-})
+mock.module('./constants', () => ({
+  PACKAGE_NAME: 'oh-my-opencode-slim',
+  USER_OPENCODE_CONFIG: '/mock/config/opencode.json',
+  USER_OPENCODE_CONFIG_JSONC: '/mock/config/opencode.jsonc',
+  INSTALLED_PACKAGE_JSON:
+    '/mock/cache/node_modules/oh-my-opencode-slim/package.json',
+}));
+
+mock.module('node:fs', () => ({
+  existsSync: mock((_p: string) => false),
+  readFileSync: mock((_p: string) => ''),
+  statSync: mock((_p: string) => ({ isDirectory: () => true })),
+  writeFileSync: mock(() => {}),
+}));
+
+describe('auto-update-checker/checker', () => {
+  describe('extractChannel', () => {
+    test('returns latest for null or empty', () => {
+      expect(extractChannel(null)).toBe('latest');
+      expect(extractChannel('')).toBe('latest');
+    });
+
+    test('returns tag if version starts with non-digit', () => {
+      expect(extractChannel('beta')).toBe('beta');
+      expect(extractChannel('next')).toBe('next');
+    });
+
+    test('extracts channel from prerelease version', () => {
+      expect(extractChannel('1.0.0-alpha.1')).toBe('alpha');
+      expect(extractChannel('2.3.4-beta.5')).toBe('beta');
+      expect(extractChannel('0.1.0-rc.1')).toBe('rc');
+      expect(extractChannel('1.0.0-canary.0')).toBe('canary');
+    });
+
+    test('returns latest for standard versions', () => {
+      expect(extractChannel('1.0.0')).toBe('latest');
+    });
+  });
+
+  describe('getLocalDevVersion', () => {
+    test('returns null if no local dev path in config', () => {
+      // existsSync returns false by default from mock
+      expect(getLocalDevVersion('/test')).toBeNull();
+    });
+
+    test('returns version from local package.json if path exists', () => {
+      const existsMock = fs.existsSync as any;
+      const readMock = fs.readFileSync as any;
+
+      existsMock.mockImplementation((p: string) => {
+        if (p.includes('opencode.json')) return true;
+        if (p.includes('package.json')) return true;
+        return false;
+      });
+
+      readMock.mockImplementation((p: string) => {
+        if (p.includes('opencode.json')) {
+          return JSON.stringify({
+            plugin: ['file:///dev/oh-my-opencode-slim'],
+          });
+        }
+        if (p.includes('package.json')) {
+          return JSON.stringify({
+            name: 'oh-my-opencode-slim',
+            version: '1.2.3-dev',
+          });
+        }
+        return '';
+      });
+
+      expect(getLocalDevVersion('/test')).toBe('1.2.3-dev');
+    });
+  });
+
+  describe('findPluginEntry', () => {
+    test('detects latest version entry', () => {
+      const existsMock = fs.existsSync as any;
+      const readMock = fs.readFileSync as any;
+
+      existsMock.mockImplementation((p: string) => p.includes('opencode.json'));
+      readMock.mockImplementation(() =>
+        JSON.stringify({
+          plugin: ['oh-my-opencode-slim'],
+        }),
+      );
+
+      const entry = findPluginEntry('/test');
+      expect(entry).not.toBeNull();
+      expect(entry?.entry).toBe('oh-my-opencode-slim');
+      expect(entry?.isPinned).toBe(false);
+      expect(entry?.pinnedVersion).toBeNull();
+    });
+
+    test('detects pinned version entry', () => {
+      const existsMock = fs.existsSync as any;
+      const readMock = fs.readFileSync as any;
+
+      existsMock.mockImplementation((p: string) => p.includes('opencode.json'));
+      readMock.mockImplementation(() =>
+        JSON.stringify({
+          plugin: ['oh-my-opencode-slim@1.0.0'],
+        }),
+      );
+
+      const entry = findPluginEntry('/test');
+      expect(entry).not.toBeNull();
+      expect(entry?.isPinned).toBe(true);
+      expect(entry?.pinnedVersion).toBe('1.0.0');
+    });
+  });
+});

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

@@ -1,31 +1,35 @@
-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, PluginEntryInfo } from "./types"
+import * as fs from 'node:fs';
+import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { stripJsonComments } from '../../cli/config-manager';
+import { log } from '../../utils/logger';
 import {
-  PACKAGE_NAME,
-  NPM_REGISTRY_URL,
-  NPM_FETCH_TIMEOUT,
   INSTALLED_PACKAGE_JSON,
+  NPM_FETCH_TIMEOUT,
+  NPM_REGISTRY_URL,
+  PACKAGE_NAME,
   USER_OPENCODE_CONFIG,
   USER_OPENCODE_CONFIG_JSONC,
-} from "./constants"
-import { log } from "../../utils/logger"
-import { stripJsonComments } from "../../cli/config-manager"
+} from './constants';
+import type {
+  NpmDistTags,
+  OpencodeConfig,
+  PackageJson,
+  PluginEntryInfo,
+} from './types';
 
 /**
  * Checks if a version string indicates a prerelease (contains a hyphen).
  */
 function isPrereleaseVersion(version: string): boolean {
-  return version.includes("-")
+  return version.includes('-');
 }
 
 /**
  * Checks if a version string is an NPM dist-tag (does not start with a digit).
  */
 function isDistTag(version: string): boolean {
-  return !/^\d/.test(version)
+  return !/^\d/.test(version);
 }
 
 /**
@@ -34,33 +38,32 @@ function isDistTag(version: string): boolean {
  * @returns The channel name.
  */
 export function extractChannel(version: string | null): string {
-  if (!version) return "latest"
+  if (!version) return 'latest';
 
-  if (isDistTag(version)) return version
+  if (isDistTag(version)) return version;
 
   if (isPrereleaseVersion(version)) {
-    const prereleasePart = version.split("-")[1]
+    const prereleasePart = version.split('-')[1];
     if (prereleasePart) {
-      const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/)
-      if (channelMatch) return channelMatch[1]
+      const channelMatch = prereleasePart.match(/^(alpha|beta|rc|canary|next)/);
+      if (channelMatch) return channelMatch[1];
     }
   }
 
-  return "latest"
+  return 'latest';
 }
 
-
 /**
  * Generates a list of potential OpenCode configuration file paths.
  * @param directory The current plugin directory to check for local .opencode folders.
  */
 function getConfigPaths(directory: string): string[] {
   return [
-    path.join(directory, ".opencode", "opencode.json"),
-    path.join(directory, ".opencode", "opencode.jsonc"),
+    path.join(directory, '.opencode', 'opencode.json'),
+    path.join(directory, '.opencode', 'opencode.jsonc'),
     USER_OPENCODE_CONFIG,
     USER_OPENCODE_CONFIG_JSONC,
-  ]
+  ];
 }
 
 /**
@@ -69,25 +72,23 @@ function getConfigPaths(directory: string): string[] {
 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 ?? []
+      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)) {
+        if (entry.startsWith('file://') && entry.includes(PACKAGE_NAME)) {
           try {
-            return fileURLToPath(entry)
+            return fileURLToPath(entry);
           } catch {
-            return entry.replace("file://", "")
+            return entry.replace('file://', '');
           }
         }
       }
-    } catch {
-      continue
-    }
+    } catch {}
   }
-  return null
+  return null;
 }
 
 /**
@@ -95,168 +96,191 @@ function getLocalDevPath(directory: string): string | null {
  */
 function findPackageJsonUp(startPath: string): string | null {
   try {
-    const stat = fs.statSync(startPath)
-    let dir = stat.isDirectory() ? startPath : path.dirname(startPath)
+    const stat = fs.statSync(startPath);
+    let dir = stat.isDirectory() ? startPath : path.dirname(startPath);
 
     for (let i = 0; i < 10; i++) {
-      const pkgPath = path.join(dir, "package.json")
+      const pkgPath = path.join(dir, 'package.json');
       if (fs.existsSync(pkgPath)) {
         try {
-          const content = fs.readFileSync(pkgPath, "utf-8")
-          const pkg = JSON.parse(content) as PackageJson
-          if (pkg.name === PACKAGE_NAME) return pkgPath
-        } catch { /* empty */ }
+          const content = fs.readFileSync(pkgPath, 'utf-8');
+          const pkg = JSON.parse(content) as PackageJson;
+          if (pkg.name === PACKAGE_NAME) return pkgPath;
+        } catch {
+          /* empty */
+        }
       }
-      const parent = path.dirname(dir)
-      if (parent === dir) break
-      dir = parent
+      const parent = path.dirname(dir);
+      if (parent === dir) break;
+      dir = parent;
     }
-  } catch { /* empty */ }
-  return null
+  } catch {
+    /* empty */
+  }
+  return null;
 }
 
 /**
  * Resolves the version of the plugin when running in local development mode.
  */
 export function getLocalDevVersion(directory: string): string | null {
-  const localPath = getLocalDevPath(directory)
-  if (!localPath) return 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
+    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
+    return null;
   }
 }
 
-
-
 /**
  * Searches across all config locations to find the current installation entry for this plugin.
  */
 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 ?? []
+      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 }
+          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 }
+          const pinnedVersion = entry.slice(PACKAGE_NAME.length + 1);
+          const isPinned = pinnedVersion !== 'latest';
+          return {
+            entry,
+            isPinned,
+            pinnedVersion: isPinned ? pinnedVersion : null,
+            configPath,
+          };
         }
       }
-    } catch {
-      continue
-    }
+    } catch {}
   }
-  return null
+  return null;
 }
 
-let cachedLocalVersion: string | null = null
-let cachedPackageVersion: string | null = null
+const _cachedLocalVersion: string | null = null;
+let cachedPackageVersion: string | null = null;
 
 /**
  * Resolves the installed version from node_modules, with memoization.
  */
 export function getCachedVersion(): string | null {
-  if (cachedPackageVersion) return cachedPackageVersion
+  if (cachedPackageVersion) return cachedPackageVersion;
 
   try {
     if (fs.existsSync(INSTALLED_PACKAGE_JSON)) {
-      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, "utf-8")
-      const pkg = JSON.parse(content) as PackageJson
+      const content = fs.readFileSync(INSTALLED_PACKAGE_JSON, 'utf-8');
+      const pkg = JSON.parse(content) as PackageJson;
       if (pkg.version) {
-        cachedPackageVersion = pkg.version
-        return pkg.version
+        cachedPackageVersion = pkg.version;
+        return pkg.version;
       }
     }
-  } catch { /* empty */ }
+  } catch {
+    /* empty */
+  }
 
   try {
-    const currentDir = path.dirname(fileURLToPath(import.meta.url))
-    const pkgPath = findPackageJsonUp(currentDir)
+    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
+      const content = fs.readFileSync(pkgPath, 'utf-8');
+      const pkg = JSON.parse(content) as PackageJson;
       if (pkg.version) {
-        cachedPackageVersion = pkg.version
-        return pkg.version
+        cachedPackageVersion = pkg.version;
+        return pkg.version;
       }
     }
   } catch (err) {
-    log("[auto-update-checker] Failed to resolve version from current directory:", err)
+    log(
+      '[auto-update-checker] Failed to resolve version from current directory:',
+      err,
+    );
   }
 
-  return null
+  return null;
 }
 
 /**
  * Safely updates a pinned version in the configuration file.
  * It attempts to replace the exact plugin string to preserve comments and formatting.
  */
-export function updatePinnedVersion(configPath: string, oldEntry: string, newVersion: string): boolean {
+export function updatePinnedVersion(
+  configPath: string,
+  oldEntry: string,
+  newVersion: string,
+): boolean {
   try {
-    if (!fs.existsSync(configPath)) return false
+    if (!fs.existsSync(configPath)) return false;
 
-    const content = fs.readFileSync(configPath, "utf-8")
-    const newEntry = `${PACKAGE_NAME}@${newVersion}`
+    const content = fs.readFileSync(configPath, 'utf-8');
+    const newEntry = `${PACKAGE_NAME}@${newVersion}`;
 
     // Check if the old entry actually exists as a quoted string
-    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-    const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, "g")
+    const escapedOldEntry = oldEntry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const entryRegex = new RegExp(`(["'])${escapedOldEntry}\\1`, 'g');
 
     if (!entryRegex.test(content)) {
-      log(`[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`)
-      return false
+      log(
+        `[auto-update-checker] Entry "${oldEntry}" not found in ${configPath}`,
+      );
+      return false;
     }
 
     // Perform the replacement
-    const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`)
+    const updatedContent = content.replace(entryRegex, `$1${newEntry}$1`);
 
     if (updatedContent === content) {
-      return false
+      return false;
     }
 
-    fs.writeFileSync(configPath, updatedContent, "utf-8")
-    log(`[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`)
-    return true
+    fs.writeFileSync(configPath, updatedContent, 'utf-8');
+    log(
+      `[auto-update-checker] Updated ${configPath}: ${oldEntry} → ${newEntry}`,
+    );
+    return true;
   } catch (err) {
-    log(`[auto-update-checker] Failed to update config file ${configPath}:`, err)
-    return false
+    log(
+      `[auto-update-checker] Failed to update config file ${configPath}:`,
+      err,
+    );
+    return false;
   }
 }
 
 /**
  * Fetches the latest version for a specific channel from the NPM registry.
  */
-export async function getLatestVersion(channel: string = "latest"): Promise<string | null> {
-  const controller = new AbortController()
-  const timeoutId = setTimeout(() => controller.abort(), NPM_FETCH_TIMEOUT)
+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" },
-    })
+      headers: { Accept: 'application/json' },
+    });
 
-    if (!response.ok) return null
+    if (!response.ok) return null;
 
-    const data = (await response.json()) as NpmDistTags
-    return data[channel] ?? data.latest ?? null
+    const data = (await response.json()) as NpmDistTags;
+    return data[channel] ?? data.latest ?? null;
   } catch {
-    return null
+    return null;
   } finally {
-    clearTimeout(timeoutId)
+    clearTimeout(timeoutId);
   }
 }

+ 16 - 16
src/hooks/auto-update-checker/constants.ts

@@ -1,33 +1,33 @@
-import * as path from "node:path"
-import * as os from "node:os"
-import { getOpenCodeConfigPaths } from "../../cli/config-manager"
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { getOpenCodeConfigPaths } from '../../cli/config-manager';
 
-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
+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")
+  if (process.platform === 'win32') {
+    return path.join(process.env.LOCALAPPDATA ?? os.homedir(), 'opencode');
   }
-  return path.join(os.homedir(), ".cache", "opencode")
+  return path.join(os.homedir(), '.cache', 'opencode');
 }
 
 /** The directory used by OpenCode to cache node_modules for plugins. */
-export const CACHE_DIR = getCacheDir()
+export const CACHE_DIR = getCacheDir();
 
 /** Path to this plugin's package.json within the OpenCode cache. */
 export const INSTALLED_PACKAGE_JSON = path.join(
   CACHE_DIR,
-  "node_modules",
+  'node_modules',
   PACKAGE_NAME,
-  "package.json"
-)
+  'package.json',
+);
 
-const configPaths = getOpenCodeConfigPaths()
+const configPaths = getOpenCodeConfigPaths();
 
 /** Primary OpenCode configuration file path (standard JSON). */
-export const USER_OPENCODE_CONFIG = configPaths[0]
+export const USER_OPENCODE_CONFIG = configPaths[0];
 
 /** Alternative OpenCode configuration file path (JSON with Comments). */
-export const USER_OPENCODE_CONFIG_JSONC = configPaths[1]
+export const USER_OPENCODE_CONFIG_JSONC = configPaths[1];

+ 149 - 78
src/hooks/auto-update-checker/index.ts

@@ -1,9 +1,16 @@
-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 { log } from "../../utils/logger"
-import type { AutoUpdateCheckerOptions } from "./types"
+import type { PluginInput } from '@opencode-ai/plugin';
+import { log } from '../../utils/logger';
+import { invalidatePackage } from './cache';
+import {
+  extractChannel,
+  findPluginEntry,
+  getCachedVersion,
+  getLatestVersion,
+  getLocalDevVersion,
+  updatePinnedVersion,
+} from './checker';
+import { PACKAGE_NAME } from './constants';
+import type { AutoUpdateCheckerOptions } from './types';
 
 /**
  * Creates an OpenCode hook that checks for plugin updates when a new session is created.
@@ -11,44 +18,59 @@ import type { AutoUpdateCheckerOptions } from "./types"
  * @param options Configuration options for the update checker.
  * @returns A hook object for the session.created event.
  */
-export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
-  const { showStartupToast = true, autoUpdate = true } = options
+export function createAutoUpdateCheckerHook(
+  ctx: PluginInput,
+  options: AutoUpdateCheckerOptions = {},
+) {
+  const { showStartupToast = true, autoUpdate = true } = options;
 
-  let hasChecked = false
+  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') return;
+      if (hasChecked) return;
 
-      const props = event.properties as { info?: { parentID?: string } } | undefined
-      if (props?.info?.parentID) return
+      const props = event.properties as
+        | { info?: { parentID?: string } }
+        | undefined;
+      if (props?.info?.parentID) return;
 
-      hasChecked = true
+      hasChecked = true;
 
       setTimeout(async () => {
-        const cachedVersion = getCachedVersion()
-        const localDevVersion = getLocalDevVersion(ctx.directory)
-        const displayVersion = localDevVersion ?? cachedVersion
+        const cachedVersion = getCachedVersion();
+        const localDevVersion = getLocalDevVersion(ctx.directory);
+        const displayVersion = localDevVersion ?? cachedVersion;
 
         if (localDevVersion) {
           if (showStartupToast) {
-            showToast(ctx, `OMO-Slim ${displayVersion} (dev)`, "Running in local development mode.", "info")
+            showToast(
+              ctx,
+              `OMO-Slim ${displayVersion} (dev)`,
+              'Running in local development mode.',
+              'info',
+            );
           }
-          log("[auto-update-checker] Local development mode")
-          return
+          log('[auto-update-checker] Local development mode');
+          return;
         }
 
         if (showStartupToast) {
-          showToast(ctx, `OMO-Slim ${displayVersion ?? "unknown"}`, "oh-my-opencode-slim is active.", "info")
+          showToast(
+            ctx,
+            `OMO-Slim ${displayVersion ?? 'unknown'}`,
+            'oh-my-opencode-slim is active.',
+            'info',
+          );
         }
 
-        runBackgroundUpdateCheck(ctx, autoUpdate).catch(err => {
-          log("[auto-update-checker] Background update check failed:", err)
-        })
-      }, 0)
+        runBackgroundUpdateCheck(ctx, autoUpdate).catch((err) => {
+          log('[auto-update-checker] Background update check failed:', err);
+        });
+      }, 0);
     },
-  }
+  };
 }
 
 /**
@@ -56,60 +78,103 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
  * @param ctx The plugin input context.
  * @param autoUpdate Whether to automatically install updates.
  */
-async function runBackgroundUpdateCheck(ctx: PluginInput, autoUpdate: boolean): Promise<void> {
-  const pluginInfo = findPluginEntry(ctx.directory)
+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
+    log('[auto-update-checker] Plugin not found in config');
+    return;
   }
 
-  const cachedVersion = getCachedVersion()
-  const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion
+  const cachedVersion = getCachedVersion();
+  const currentVersion = cachedVersion ?? pluginInfo.pinnedVersion;
   if (!currentVersion) {
-    log("[auto-update-checker] No version found (cached or pinned)")
-    return
+    log('[auto-update-checker] No version found (cached or pinned)');
+    return;
   }
 
-  const channel = extractChannel(pluginInfo.pinnedVersion ?? currentVersion)
-  const latestVersion = await getLatestVersion(channel)
+  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
+    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
+    log(
+      '[auto-update-checker] Already on latest version for channel:',
+      channel,
+    );
+    return;
   }
 
-  log(`[auto-update-checker] Update available (${channel}): ${currentVersion} → ${latestVersion}`)
+  log(
+    `[auto-update-checker] 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
+    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)
+    const updated = updatePinnedVersion(
+      pluginInfo.configPath,
+      pluginInfo.entry,
+      latestVersion,
+    );
     if (!updated) {
-      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
+      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}`)
+    log(
+      `[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`,
+    );
   }
 
-  invalidatePackage(PACKAGE_NAME)
+  invalidatePackage(PACKAGE_NAME);
 
-  const installSuccess = await runBunInstallSafe(ctx)
+  const installSuccess = await runBunInstallSafe(ctx);
 
   if (installSuccess) {
-    showToast(ctx, "OMO-Slim Updated!", `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`, "success", 8000)
-    log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
+    showToast(
+      ctx,
+      'OMO-Slim Updated!',
+      `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`,
+      'success',
+      8000,
+    );
+    log(
+      `[auto-update-checker] 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")
+    showToast(
+      ctx,
+      `OMO-Slim ${latestVersion}`,
+      `v${latestVersion} available. Restart to apply.`,
+      'info',
+      8000,
+    );
+    log('[auto-update-checker] bun install failed; update not installed');
   }
 }
 
@@ -121,27 +186,31 @@ async function runBackgroundUpdateCheck(ctx: PluginInput, autoUpdate: boolean):
  */
 async function runBunInstallSafe(ctx: PluginInput): Promise<boolean> {
   try {
-    const proc = Bun.spawn(["bun", "install"], {
+    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
+      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;
     }
 
-    return proc.exitCode === 0
+    return proc.exitCode === 0;
   } catch (err) {
-    log("[auto-update-checker] bun install error:", err)
-    return false
+    log('[auto-update-checker] bun install error:', err);
+    return false;
   }
 }
 
@@ -157,12 +226,14 @@ function showToast(
   ctx: PluginInput,
   title: string,
   message: string,
-  variant: "info" | "success" | "error" = "info",
-  duration = 3000
+  variant: 'info' | 'success' | 'error' = 'info',
+  duration = 3000,
 ): void {
-  ctx.client.tui.showToast({
-    body: { title, message, variant, duration },
-  }).catch(() => { })
+  ctx.client.tui
+    .showToast({
+      body: { title, message, variant, duration },
+    })
+    .catch(() => {});
 }
 
-export type { AutoUpdateCheckerOptions } from "./types"
+export type { AutoUpdateCheckerOptions } from './types';

+ 13 - 13
src/hooks/auto-update-checker/types.ts

@@ -1,27 +1,27 @@
 export interface NpmDistTags {
-  latest: string
-  [key: string]: string
+  latest: string;
+  [key: string]: string;
 }
 
 export interface OpencodeConfig {
-  plugin?: string[]
-  [key: string]: unknown
+  plugin?: string[];
+  [key: string]: unknown;
 }
 
 export interface PackageJson {
-  version: string
-  name?: string
-  [key: string]: unknown
+  version: string;
+  name?: string;
+  [key: string]: unknown;
 }
 
 export interface AutoUpdateCheckerOptions {
-  showStartupToast?: boolean
-  autoUpdate?: boolean
+  showStartupToast?: boolean;
+  autoUpdate?: boolean;
 }
 
 export interface PluginEntryInfo {
-  entry: string
-  isPinned: boolean
-  pinnedVersion: string | null
-  configPath: string
+  entry: string;
+  isPinned: boolean;
+  pinnedVersion: string | null;
+  configPath: string;
 }

+ 4 - 4
src/hooks/index.ts

@@ -1,4 +1,4 @@
-export { createAutoUpdateCheckerHook } from "./auto-update-checker"
-export type { AutoUpdateCheckerOptions } from "./auto-update-checker"
-export { createPhaseReminderHook } from "./phase-reminder"
-export { createPostReadNudgeHook } from "./post-read-nudge"
+export type { AutoUpdateCheckerOptions } from './auto-update-checker';
+export { createAutoUpdateCheckerHook } from './auto-update-checker';
+export { createPhaseReminderHook } from './phase-reminder';
+export { createPostReadNudgeHook } from './post-read-nudge';

+ 12 - 11
src/hooks/phase-reminder/index.ts

@@ -2,10 +2,10 @@
  * Phase reminder to inject before each user message.
  * Keeps workflow instructions in the immediate attention window
  * to combat instruction-following degradation over long contexts.
- * 
+ *
  * Research: "LLMs Get Lost In Multi-Turn Conversation" (arXiv:2505.06120)
  * shows ~40% compliance drop after 2-3 turns without reminders.
- * 
+ *
  * 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
@@ -36,12 +36,12 @@ interface MessageWithParts {
  */
 export function createPhaseReminderHook() {
   return {
-    "experimental.chat.messages.transform": async (
+    'experimental.chat.messages.transform': async (
       _input: Record<string, never>,
-      output: { messages: MessageWithParts[] }
+      output: { messages: MessageWithParts[] },
     ): Promise<void> => {
       const { messages } = output;
-      
+
       if (messages.length === 0) {
         return;
       }
@@ -49,7 +49,7 @@ export function createPhaseReminderHook() {
       // Find the last user message
       let lastUserMessageIndex = -1;
       for (let i = messages.length - 1; i >= 0; i--) {
-        if (messages[i].info.role === "user") {
+        if (messages[i].info.role === 'user') {
           lastUserMessageIndex = i;
           break;
         }
@@ -60,16 +60,16 @@ export function createPhaseReminderHook() {
       }
 
       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 !== 'orchestrator') {
         return;
       }
 
       // Find the first text part
       const textPartIndex = lastUserMessage.parts.findIndex(
-        (p) => p.type === "text" && p.text !== undefined
+        (p) => p.type === 'text' && p.text !== undefined,
       );
 
       if (textPartIndex === -1) {
@@ -77,8 +77,9 @@ export function createPhaseReminderHook() {
       }
 
       // Prepend the reminder to the existing text
-      const originalText = lastUserMessage.parts[textPartIndex].text ?? "";
-      lastUserMessage.parts[textPartIndex].text = `${PHASE_REMINDER}\n\n---\n\n${originalText}`;
+      const originalText = lastUserMessage.parts[textPartIndex].text ?? '';
+      lastUserMessage.parts[textPartIndex].text =
+        `${PHASE_REMINDER}\n\n---\n\n${originalText}`;
     },
   };
 }

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

@@ -3,7 +3,8 @@
  * 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)";
+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;
@@ -19,12 +20,12 @@ interface ToolExecuteAfterOutput {
 
 export function createPostReadNudgeHook() {
   return {
-    "tool.execute.after": async (
+    'tool.execute.after': async (
       input: ToolExecuteAfterInput,
-      output: ToolExecuteAfterOutput
+      output: ToolExecuteAfterOutput,
     ): Promise<void> => {
       // Only nudge for Read tool
-      if (input.tool !== "Read" && input.tool !== "read") {
+      if (input.tool !== 'Read' && input.tool !== 'read') {
         return;
       }
 

+ 58 - 32
src/index.ts

@@ -1,24 +1,28 @@
-import type { Plugin } from "@opencode-ai/plugin";
-import { getAgentConfigs } from "./agents";
-import { BackgroundTaskManager, TmuxSessionManager } from "./background";
+import type { Plugin } from '@opencode-ai/plugin';
+import { getAgentConfigs } from './agents';
+import { BackgroundTaskManager, TmuxSessionManager } from './background';
+import { loadPluginConfig, type TmuxConfig } from './config';
 import {
+  createAutoUpdateCheckerHook,
+  createPhaseReminderHook,
+  createPostReadNudgeHook,
+} from './hooks';
+import { createBuiltinMcps } from './mcp';
+import {
+  antigravity_quota,
+  ast_grep_replace,
+  ast_grep_search,
   createBackgroundTools,
-  lsp_goto_definition,
-  lsp_find_references,
+  createSkillTools,
+  grep,
   lsp_diagnostics,
+  lsp_find_references,
+  lsp_goto_definition,
   lsp_rename,
-  grep,
-  ast_grep_search,
-  ast_grep_replace,
-  antigravity_quota,
-  createSkillTools,
   SkillMcpManager,
-} from "./tools";
-import { loadPluginConfig, type TmuxConfig } from "./config";
-import { createBuiltinMcps } from "./mcp";
-import { createAutoUpdateCheckerHook, createPhaseReminderHook, createPostReadNudgeHook } from "./hooks";
-import { startTmuxCheck } from "./utils";
-import { log } from "./utils/logger";
+} from './tools';
+import { startTmuxCheck } from './utils';
+import { log } from './utils/logger';
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const config = loadPluginConfig(ctx.directory);
@@ -27,14 +31,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   // Parse tmux config with defaults
   const tmuxConfig: TmuxConfig = {
     enabled: config.tmux?.enabled ?? false,
-    layout: config.tmux?.layout ?? "main-vertical",
+    layout: config.tmux?.layout ?? 'main-vertical',
     main_pane_size: config.tmux?.main_pane_size ?? 60,
   };
 
-  log("[plugin] initialized with tmux config", {
+  log('[plugin] initialized with tmux config', {
     tmuxConfig,
     rawTmuxConfig: config.tmux,
-    directory: ctx.directory
+    directory: ctx.directory,
   });
 
   // Start background tmux check if enabled
@@ -43,7 +47,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   }
 
   const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig, config);
-  const backgroundTools = createBackgroundTools(ctx, backgroundManager, tmuxConfig, config);
+  const backgroundTools = createBackgroundTools(
+    ctx,
+    backgroundManager,
+    tmuxConfig,
+    config,
+  );
   const mcps = createBuiltinMcps(config.disabled_mcps);
   const skillMcpManager = SkillMcpManager.getInstance();
   const skillTools = createSkillTools(skillMcpManager, config);
@@ -64,7 +73,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const postReadNudgeHook = createPostReadNudgeHook();
 
   return {
-    name: "oh-my-opencode-slim",
+    name: 'oh-my-opencode-slim',
 
     agent: agents,
 
@@ -84,9 +93,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     mcp: mcps,
 
     config: async (opencodeConfig: Record<string, unknown>) => {
-      (opencodeConfig as { default_agent?: string }).default_agent = "orchestrator";
+      (opencodeConfig as { default_agent?: string }).default_agent =
+        'orchestrator';
 
-      const configAgent = opencodeConfig.agent as Record<string, unknown> | undefined;
+      const configAgent = opencodeConfig.agent as
+        | Record<string, unknown>
+        | undefined;
       if (!configAgent) {
         opencodeConfig.agent = { ...agents };
       } else {
@@ -94,7 +106,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       // Merge MCP configs
-      const configMcp = opencodeConfig.mcp as Record<string, unknown> | undefined;
+      const configMcp = opencodeConfig.mcp as
+        | Record<string, unknown>
+        | undefined;
       if (!configMcp) {
         opencodeConfig.mcp = { ...mcps };
       } else {
@@ -107,21 +121,33 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await autoUpdateChecker.event(input);
 
       // Handle tmux pane spawning for OpenCode's Task tool sessions
-      await tmuxSessionManager.onSessionCreated(input.event as {
-        type: string;
-        properties?: { info?: { id?: string; parentID?: string; title?: string } };
-      });
+      await tmuxSessionManager.onSessionCreated(
+        input.event as {
+          type: string;
+          properties?: {
+            info?: { id?: string; parentID?: string; title?: string };
+          };
+        },
+      );
     },
 
     // Inject phase reminder before sending to API (doesn't show in UI)
-    "experimental.chat.messages.transform": phaseReminderHook["experimental.chat.messages.transform"],
+    'experimental.chat.messages.transform':
+      phaseReminderHook['experimental.chat.messages.transform'],
 
     // Nudge after file reads to encourage delegation
-    "tool.execute.after": postReadNudgeHook["tool.execute.after"],
+    'tool.execute.after': postReadNudgeHook['tool.execute.after'],
   };
 };
 
 export default OhMyOpenCodeLite;
 
-export type { PluginConfig, AgentOverrideConfig, AgentName, McpName, TmuxConfig, TmuxLayout } from "./config";
-export type { RemoteMcpConfig } from "./mcp";
+export type {
+  AgentName,
+  AgentOverrideConfig,
+  McpName,
+  PluginConfig,
+  TmuxConfig,
+  TmuxLayout,
+} from './config';
+export type { RemoteMcpConfig } from './mcp';

+ 4 - 4
src/mcp/context7.ts

@@ -1,14 +1,14 @@
-import type { RemoteMcpConfig } from "./types";
+import type { RemoteMcpConfig } from './types';
 
 /**
  * Context7 - official documentation lookup for libraries
  * @see https://context7.com
  */
 export const context7: RemoteMcpConfig = {
-  type: "remote",
-  url: "https://mcp.context7.com/mcp",
+  type: 'remote',
+  url: 'https://mcp.context7.com/mcp',
   headers: process.env.CONTEXT7_API_KEY
-    ? { "CONTEXT7_API_KEY": process.env.CONTEXT7_API_KEY }
+    ? { CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY }
     : undefined,
   oauth: false,
 };

+ 3 - 3
src/mcp/grep-app.ts

@@ -1,11 +1,11 @@
-import type { RemoteMcpConfig } from "./types";
+import type { RemoteMcpConfig } from './types';
 
 /**
  * grep.app - ultra-fast code search across GitHub repositories
  * @see https://grep.app
  */
 export const grep_app: RemoteMcpConfig = {
-  type: "remote",
-  url: "https://mcp.grep.app",
+  type: 'remote',
+  url: 'https://mcp.grep.app',
   oauth: false,
 };

+ 93 - 93
src/mcp/index.test.ts

@@ -1,96 +1,96 @@
-import { describe, expect, test } from "bun:test"
-import { createBuiltinMcps } from "./index"
-
-describe("createBuiltinMcps", () => {
-  test("returns all MCPs when no disabled list provided", () => {
-    const mcps = createBuiltinMcps()
-    const names = Object.keys(mcps)
-    
-    expect(names).toContain("websearch")
-    expect(names).toContain("context7")
-    expect(names).toContain("grep_app")
-  })
-
-  test("returns all MCPs with empty disabled list", () => {
-    const mcps = createBuiltinMcps([])
-    const names = Object.keys(mcps)
-    
-    expect(names.length).toBe(3)
-    expect(names).toContain("websearch")
-    expect(names).toContain("context7")
-    expect(names).toContain("grep_app")
-  })
-
-  test("excludes single disabled MCP", () => {
-    const mcps = createBuiltinMcps(["websearch"])
-    const names = Object.keys(mcps)
-    
-    expect(names).not.toContain("websearch")
-    expect(names).toContain("context7")
-    expect(names).toContain("grep_app")
-  })
-
-  test("excludes multiple disabled MCPs", () => {
-    const mcps = createBuiltinMcps(["websearch", "grep_app"])
-    const names = Object.keys(mcps)
-    
-    expect(names).not.toContain("websearch")
-    expect(names).not.toContain("grep_app")
-    expect(names).toContain("context7")
-    expect(names.length).toBe(1)
-  })
-
-  test("excludes all MCPs when all disabled", () => {
-    const mcps = createBuiltinMcps(["websearch", "context7", "grep_app"])
-    const names = Object.keys(mcps)
-    
-    expect(names.length).toBe(0)
-  })
-
-  test("ignores unknown MCP names in disabled list", () => {
-    const mcps = createBuiltinMcps(["unknown_mcp", "nonexistent"])
-    const names = Object.keys(mcps)
-    
+import { describe, expect, test } from 'bun:test';
+import { createBuiltinMcps } from './index';
+
+describe('createBuiltinMcps', () => {
+  test('returns all MCPs when no disabled list provided', () => {
+    const mcps = createBuiltinMcps();
+    const names = Object.keys(mcps);
+
+    expect(names).toContain('websearch');
+    expect(names).toContain('context7');
+    expect(names).toContain('grep_app');
+  });
+
+  test('returns all MCPs with empty disabled list', () => {
+    const mcps = createBuiltinMcps([]);
+    const names = Object.keys(mcps);
+
+    expect(names.length).toBe(3);
+    expect(names).toContain('websearch');
+    expect(names).toContain('context7');
+    expect(names).toContain('grep_app');
+  });
+
+  test('excludes single disabled MCP', () => {
+    const mcps = createBuiltinMcps(['websearch']);
+    const names = Object.keys(mcps);
+
+    expect(names).not.toContain('websearch');
+    expect(names).toContain('context7');
+    expect(names).toContain('grep_app');
+  });
+
+  test('excludes multiple disabled MCPs', () => {
+    const mcps = createBuiltinMcps(['websearch', 'grep_app']);
+    const names = Object.keys(mcps);
+
+    expect(names).not.toContain('websearch');
+    expect(names).not.toContain('grep_app');
+    expect(names).toContain('context7');
+    expect(names.length).toBe(1);
+  });
+
+  test('excludes all MCPs when all disabled', () => {
+    const mcps = createBuiltinMcps(['websearch', 'context7', 'grep_app']);
+    const names = Object.keys(mcps);
+
+    expect(names.length).toBe(0);
+  });
+
+  test('ignores unknown MCP names in disabled list', () => {
+    const mcps = createBuiltinMcps(['unknown_mcp', 'nonexistent']);
+    const names = Object.keys(mcps);
+
     // All valid MCPs should still be present
-    expect(names.length).toBe(3)
-    expect(names).toContain("websearch")
-    expect(names).toContain("context7")
-    expect(names).toContain("grep_app")
-  })
-
-  test("MCP configs have required properties", () => {
-    const mcps = createBuiltinMcps()
-    
-    for (const [name, config] of Object.entries(mcps)) {
-      expect(config).toBeDefined()
+    expect(names.length).toBe(3);
+    expect(names).toContain('websearch');
+    expect(names).toContain('context7');
+    expect(names).toContain('grep_app');
+  });
+
+  test('MCP configs have required properties', () => {
+    const mcps = createBuiltinMcps();
+
+    for (const [_name, config] of Object.entries(mcps)) {
+      expect(config).toBeDefined();
       // Each MCP should have either url (remote) or command (local)
-      const hasUrl = "url" in config
-      const hasCommand = "command" in config
-      expect(hasUrl || hasCommand).toBe(true)
+      const hasUrl = 'url' in config;
+      const hasCommand = 'command' in config;
+      expect(hasUrl || hasCommand).toBe(true);
     }
-  })
-
-  test("websearch MCP has correct structure", () => {
-    const mcps = createBuiltinMcps()
-    const websearch = mcps.websearch
-    
-    expect(websearch).toBeDefined()
-    expect("url" in websearch).toBe(true)
-  })
-
-  test("context7 MCP has correct structure", () => {
-    const mcps = createBuiltinMcps()
-    const context7 = mcps.context7
-    
-    expect(context7).toBeDefined()
-    expect("url" in context7).toBe(true)
-  })
-
-  test("grep_app MCP has correct structure", () => {
-    const mcps = createBuiltinMcps()
-    const grep_app = mcps.grep_app
-    
-    expect(grep_app).toBeDefined()
-    expect("url" in grep_app).toBe(true)
-  })
-})
+  });
+
+  test('websearch MCP has correct structure', () => {
+    const mcps = createBuiltinMcps();
+    const websearch = mcps.websearch;
+
+    expect(websearch).toBeDefined();
+    expect('url' in websearch).toBe(true);
+  });
+
+  test('context7 MCP has correct structure', () => {
+    const mcps = createBuiltinMcps();
+    const context7 = mcps.context7;
+
+    expect(context7).toBeDefined();
+    expect('url' in context7).toBe(true);
+  });
+
+  test('grep_app MCP has correct structure', () => {
+    const mcps = createBuiltinMcps();
+    const grep_app = mcps.grep_app;
+
+    expect(grep_app).toBeDefined();
+    expect('url' in grep_app).toBe(true);
+  });
+});

+ 10 - 8
src/mcp/index.ts

@@ -1,10 +1,10 @@
-import { websearch } from "./websearch";
-import { context7 } from "./context7";
-import { grep_app } from "./grep-app";
-import type { McpConfig } from "./types";
-import type { McpName } from "../config";
+import type { McpName } from '../config';
+import { context7 } from './context7';
+import { grep_app } from './grep-app';
+import type { McpConfig } from './types';
+import { websearch } from './websearch';
 
-export type { RemoteMcpConfig, LocalMcpConfig, McpConfig } from "./types";
+export type { LocalMcpConfig, McpConfig, RemoteMcpConfig } from './types';
 
 const allBuiltinMcps: Record<McpName, McpConfig> = {
   websearch,
@@ -16,9 +16,11 @@ const allBuiltinMcps: Record<McpName, McpConfig> = {
  * Creates MCP configurations, excluding disabled ones
  */
 export function createBuiltinMcps(
-  disabledMcps: readonly string[] = []
+  disabledMcps: readonly string[] = [],
 ): Record<string, McpConfig> {
   return Object.fromEntries(
-    Object.entries(allBuiltinMcps).filter(([name]) => !disabledMcps.includes(name))
+    Object.entries(allBuiltinMcps).filter(
+      ([name]) => !disabledMcps.includes(name),
+    ),
   );
 }

+ 2 - 2
src/mcp/types.ts

@@ -1,14 +1,14 @@
 // MCP types - McpName is defined in config/schema.ts to avoid duplication
 
 export type RemoteMcpConfig = {
-  type: "remote";
+  type: 'remote';
   url: string;
   headers?: Record<string, string>;
   oauth?: false;
 };
 
 export type LocalMcpConfig = {
-  type: "local";
+  type: 'local';
   command: string[];
   environment?: Record<string, string>;
 };

+ 4 - 4
src/mcp/websearch.ts

@@ -1,14 +1,14 @@
-import type { RemoteMcpConfig } from "./types";
+import type { RemoteMcpConfig } from './types';
 
 /**
  * Exa AI web search - real-time web search
  * @see https://exa.ai
  */
 export const websearch: RemoteMcpConfig = {
-  type: "remote",
-  url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
+  type: 'remote',
+  url: 'https://mcp.exa.ai/mcp?tools=web_search_exa',
   headers: process.env.EXA_API_KEY
-    ? { "x-api-key": process.env.EXA_API_KEY }
+    ? { 'x-api-key': process.env.EXA_API_KEY }
     : undefined,
   oauth: false,
 };

+ 122 - 99
src/tools/ast-grep/cli.ts

@@ -1,142 +1,153 @@
-import { spawn } from "bun"
-import { existsSync } from "node:fs"
+import { existsSync } from 'node:fs';
+import { spawn } from 'bun';
 import {
+  DEFAULT_MAX_MATCHES,
+  DEFAULT_MAX_OUTPUT_BYTES,
+  DEFAULT_TIMEOUT_MS,
+  findSgCliPathSync,
   getSgCliPath,
   setSgCliPath,
-  findSgCliPathSync,
-  DEFAULT_TIMEOUT_MS,
-  DEFAULT_MAX_OUTPUT_BYTES,
-  DEFAULT_MAX_MATCHES,
-} from "./constants"
-import { ensureAstGrepBinary } from "./downloader"
-import type { CliMatch, CliLanguage, SgResult } from "./types"
+} from './constants';
+import { ensureAstGrepBinary } from './downloader';
+import type { CliLanguage, CliMatch, SgResult } from './types';
 
 export interface RunOptions {
-  pattern: string
-  lang: CliLanguage
-  paths?: string[]
-  globs?: string[]
-  rewrite?: string
-  context?: number
-  updateAll?: boolean
+  pattern: string;
+  lang: CliLanguage;
+  paths?: string[];
+  globs?: string[];
+  rewrite?: string;
+  context?: number;
+  updateAll?: boolean;
 }
 
 // Use a single init promise to avoid race conditions
-let initPromise: Promise<string | null> | null = null
+let initPromise: Promise<string | null> | null = null;
 
 export async function getAstGrepPath(): Promise<string | null> {
-  const currentPath = getSgCliPath()
-  if (currentPath !== "sg" && existsSync(currentPath)) {
-    return currentPath
+  const currentPath = getSgCliPath();
+  if (currentPath !== 'sg' && existsSync(currentPath)) {
+    return currentPath;
   }
 
   if (initPromise) {
-    return initPromise
+    return initPromise;
   }
 
   initPromise = (async () => {
-    const syncPath = findSgCliPathSync()
+    const syncPath = findSgCliPathSync();
     if (syncPath && existsSync(syncPath)) {
-      setSgCliPath(syncPath)
-      return syncPath
+      setSgCliPath(syncPath);
+      return syncPath;
     }
 
-    const downloadedPath = await ensureAstGrepBinary()
+    const downloadedPath = await ensureAstGrepBinary();
     if (downloadedPath) {
-      setSgCliPath(downloadedPath)
-      return downloadedPath
+      setSgCliPath(downloadedPath);
+      return downloadedPath;
     }
 
-    return null
-  })()
+    return null;
+  })();
 
-  return initPromise
+  return initPromise;
 }
 
 export function startBackgroundInit(): void {
   if (!initPromise) {
-    initPromise = getAstGrepPath()
-    initPromise.catch(() => {})
+    initPromise = getAstGrepPath();
+    initPromise.catch(() => {});
   }
 }
 
 export async function runSg(options: RunOptions): Promise<SgResult> {
-  const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
+  const args = [
+    'run',
+    '-p',
+    options.pattern,
+    '--lang',
+    options.lang,
+    '--json=compact',
+  ];
 
   if (options.rewrite) {
-    args.push("-r", options.rewrite)
+    args.push('-r', options.rewrite);
     if (options.updateAll) {
-      args.push("--update-all")
+      args.push('--update-all');
     }
   }
 
   if (options.context && options.context > 0) {
-    args.push("-C", String(options.context))
+    args.push('-C', String(options.context));
   }
 
   if (options.globs) {
     for (const glob of options.globs) {
-      args.push("--globs", glob)
+      args.push('--globs', glob);
     }
   }
 
-  const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
-  args.push(...paths)
+  const paths =
+    options.paths && options.paths.length > 0 ? options.paths : ['.'];
+  args.push(...paths);
 
-  let cliPath = getSgCliPath()
+  let cliPath = getSgCliPath();
 
-  if (!existsSync(cliPath) && cliPath !== "sg") {
-    const downloadedPath = await getAstGrepPath()
+  if (!existsSync(cliPath) && cliPath !== 'sg') {
+    const downloadedPath = await getAstGrepPath();
     if (downloadedPath) {
-      cliPath = downloadedPath
+      cliPath = downloadedPath;
     }
   }
 
-  const timeout = DEFAULT_TIMEOUT_MS
+  const timeout = DEFAULT_TIMEOUT_MS;
 
   const proc = spawn([cliPath, ...args], {
-    stdout: "pipe",
-    stderr: "pipe",
-  })
+    stdout: 'pipe',
+    stderr: 'pipe',
+  });
 
   const timeoutPromise = new Promise<never>((_, reject) => {
     const id = setTimeout(() => {
-      proc.kill()
-      reject(new Error(`Search timeout after ${timeout}ms`))
-    }, timeout)
-    proc.exited.then(() => clearTimeout(id))
-  })
+      proc.kill();
+      reject(new Error(`Search timeout after ${timeout}ms`));
+    }, timeout);
+    proc.exited.then(() => clearTimeout(id));
+  });
 
-  let stdout: string
-  let stderr: string
-  let exitCode: number
+  let stdout: string;
+  let stderr: string;
+  let exitCode: number;
 
   try {
-    stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
-    stderr = await new Response(proc.stderr).text()
-    exitCode = await proc.exited
+    stdout = await Promise.race([
+      new Response(proc.stdout).text(),
+      timeoutPromise,
+    ]);
+    stderr = await new Response(proc.stderr).text();
+    exitCode = await proc.exited;
   } catch (e) {
-    const error = e as Error
-    if (error.message?.includes("timeout")) {
+    const error = e as Error;
+    if (error.message?.includes('timeout')) {
       return {
         matches: [],
         totalMatches: 0,
         truncated: true,
-        truncatedReason: "timeout",
+        truncatedReason: 'timeout',
         error: error.message,
-      }
+      };
     }
 
-    const nodeError = e as NodeJS.ErrnoException
+    const nodeError = e as NodeJS.ErrnoException;
     if (
-      nodeError.code === "ENOENT" ||
-      nodeError.message?.includes("ENOENT") ||
-      nodeError.message?.includes("not found")
+      nodeError.code === 'ENOENT' ||
+      nodeError.message?.includes('ENOENT') ||
+      nodeError.message?.includes('not found')
     ) {
-      const downloadedPath = await ensureAstGrepBinary()
+      const downloadedPath = await ensureAstGrepBinary();
       if (downloadedPath) {
-        setSgCliPath(downloadedPath)
-        return runSg(options)
+        setSgCliPath(downloadedPath);
+        return runSg(options);
       } else {
         return {
           matches: [],
@@ -148,7 +159,7 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
             `  bun add -D @ast-grep/cli\n` +
             `  cargo install ast-grep --locked\n` +
             `  brew install ast-grep`,
-        }
+        };
       }
     }
 
@@ -157,38 +168,48 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
       totalMatches: 0,
       truncated: false,
       error: `Failed to spawn ast-grep: ${error.message}`,
-    }
+    };
   }
 
-  if (exitCode !== 0 && stdout.trim() === "") {
-    if (stderr.includes("No files found")) {
-      return { matches: [], totalMatches: 0, truncated: false }
+  if (exitCode !== 0 && stdout.trim() === '') {
+    if (stderr.includes('No files found')) {
+      return { matches: [], totalMatches: 0, truncated: false };
     }
     if (stderr.trim()) {
-      return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
+      return {
+        matches: [],
+        totalMatches: 0,
+        truncated: false,
+        error: stderr.trim(),
+      };
     }
-    return { matches: [], totalMatches: 0, truncated: false }
+    return { matches: [], totalMatches: 0, truncated: false };
   }
 
   if (!stdout.trim()) {
-    return { matches: [], totalMatches: 0, truncated: false }
+    return { matches: [], totalMatches: 0, truncated: false };
   }
 
-  const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
-  const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
+  const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES;
+  const outputToProcess = outputTruncated
+    ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES)
+    : stdout;
 
-  let matches: CliMatch[] = []
+  let matches: CliMatch[] = [];
   try {
-    matches = JSON.parse(outputToProcess) as CliMatch[]
+    matches = JSON.parse(outputToProcess) as CliMatch[];
   } catch {
     if (outputTruncated) {
       try {
-        const lastValidIndex = outputToProcess.lastIndexOf("}")
+        const lastValidIndex = outputToProcess.lastIndexOf('}');
         if (lastValidIndex > 0) {
-          const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
+          const bracketIndex = outputToProcess.lastIndexOf(
+            '},',
+            lastValidIndex,
+          );
           if (bracketIndex > 0) {
-            const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
-            matches = JSON.parse(truncatedJson) as CliMatch[]
+            const truncatedJson = `${outputToProcess.substring(0, bracketIndex + 1)}]`;
+            matches = JSON.parse(truncatedJson) as CliMatch[];
           }
         }
       } catch {
@@ -196,37 +217,39 @@ export async function runSg(options: RunOptions): Promise<SgResult> {
           matches: [],
           totalMatches: 0,
           truncated: true,
-          truncatedReason: "max_output_bytes",
-          error: "Output too large and could not be parsed",
-        }
+          truncatedReason: 'max_output_bytes',
+          error: 'Output too large and could not be parsed',
+        };
       }
     } else {
-      return { matches: [], totalMatches: 0, truncated: false }
+      return { matches: [], totalMatches: 0, truncated: false };
     }
   }
 
-  const totalMatches = matches.length
-  const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
-  const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
+  const totalMatches = matches.length;
+  const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES;
+  const finalMatches = matchesTruncated
+    ? matches.slice(0, DEFAULT_MAX_MATCHES)
+    : matches;
 
   return {
     matches: finalMatches,
     totalMatches,
     truncated: outputTruncated || matchesTruncated,
     truncatedReason: outputTruncated
-      ? "max_output_bytes"
+      ? 'max_output_bytes'
       : matchesTruncated
-        ? "max_matches"
+        ? 'max_matches'
         : undefined,
-  }
+  };
 }
 
 export function isCliAvailable(): boolean {
-  const path = findSgCliPathSync()
-  return path !== null && existsSync(path)
+  const path = findSgCliPathSync();
+  return path !== null && existsSync(path);
 }
 
 export async function ensureCliAvailable(): Promise<boolean> {
-  const path = await getAstGrepPath()
-  return path !== null && existsSync(path)
+  const path = await getAstGrepPath();
+  return path !== null && existsSync(path);
 }

+ 109 - 103
src/tools/ast-grep/constants.ts

@@ -1,153 +1,154 @@
-import { createRequire } from "node:module"
-import { dirname, join } from "node:path"
-import { existsSync, statSync } from "node:fs"
-import { spawnSync } from "node:child_process"
-import { getCachedBinaryPath } from "./downloader"
-import { CLI_LANGUAGES } from "./types"
+import { spawnSync } from 'node:child_process';
+import { existsSync, statSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { dirname, join } from 'node:path';
+import { getCachedBinaryPath } from './downloader';
+import { CLI_LANGUAGES } from './types';
 
-type Platform = "darwin" | "linux" | "win32" | "unsupported"
+type Platform = 'darwin' | 'linux' | 'win32' | 'unsupported';
 
 // Minimum expected size for a valid sg binary (filters out stub files)
-const MIN_BINARY_SIZE = 10_000
+const MIN_BINARY_SIZE = 10_000;
 
 function isValidBinary(filePath: string): boolean {
   try {
-    return statSync(filePath).size > MIN_BINARY_SIZE
+    return statSync(filePath).size > MIN_BINARY_SIZE;
   } catch {
-    return false
+    return false;
   }
 }
 
 function getPlatformPackageName(): string | null {
-  const platform = process.platform as Platform
-  const arch = process.arch
+  const platform = process.platform as Platform;
+  const arch = process.arch;
 
   const platformMap: Record<string, string> = {
-    "darwin-arm64": "@ast-grep/cli-darwin-arm64",
-    "darwin-x64": "@ast-grep/cli-darwin-x64",
-    "linux-arm64": "@ast-grep/cli-linux-arm64-gnu",
-    "linux-x64": "@ast-grep/cli-linux-x64-gnu",
-    "win32-x64": "@ast-grep/cli-win32-x64-msvc",
-    "win32-arm64": "@ast-grep/cli-win32-arm64-msvc",
-    "win32-ia32": "@ast-grep/cli-win32-ia32-msvc",
-  }
-
-  return platformMap[`${platform}-${arch}`] ?? null
+    'darwin-arm64': '@ast-grep/cli-darwin-arm64',
+    'darwin-x64': '@ast-grep/cli-darwin-x64',
+    'linux-arm64': '@ast-grep/cli-linux-arm64-gnu',
+    'linux-x64': '@ast-grep/cli-linux-x64-gnu',
+    'win32-x64': '@ast-grep/cli-win32-x64-msvc',
+    'win32-arm64': '@ast-grep/cli-win32-arm64-msvc',
+    'win32-ia32': '@ast-grep/cli-win32-ia32-msvc',
+  };
+
+  return platformMap[`${platform}-${arch}`] ?? null;
 }
 
 // Single source of truth for resolved CLI path
-let resolvedCliPath: string | null = null
+let resolvedCliPath: string | null = null;
 
 export function findSgCliPathSync(): string | null {
-  const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
+  const binaryName = process.platform === 'win32' ? 'sg.exe' : 'sg';
 
-  const cachedPath = getCachedBinaryPath()
+  const cachedPath = getCachedBinaryPath();
   if (cachedPath && isValidBinary(cachedPath)) {
-    return cachedPath
+    return cachedPath;
   }
 
   try {
-    const require = createRequire(import.meta.url)
-    const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
-    const cliDir = dirname(cliPkgPath)
-    const sgPath = join(cliDir, binaryName)
+    const require = createRequire(import.meta.url);
+    const cliPkgPath = require.resolve('@ast-grep/cli/package.json');
+    const cliDir = dirname(cliPkgPath);
+    const sgPath = join(cliDir, binaryName);
 
     if (existsSync(sgPath) && isValidBinary(sgPath)) {
-      return sgPath
+      return sgPath;
     }
   } catch {
     // @ast-grep/cli not installed
   }
 
-  const platformPkg = getPlatformPackageName()
+  const platformPkg = getPlatformPackageName();
   if (platformPkg) {
     try {
-      const require = createRequire(import.meta.url)
-      const pkgPath = require.resolve(`${platformPkg}/package.json`)
-      const pkgDir = dirname(pkgPath)
-      const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
-      const binaryPath = join(pkgDir, astGrepName)
+      const require = createRequire(import.meta.url);
+      const pkgPath = require.resolve(`${platformPkg}/package.json`);
+      const pkgDir = dirname(pkgPath);
+      const astGrepName =
+        process.platform === 'win32' ? 'ast-grep.exe' : 'ast-grep';
+      const binaryPath = join(pkgDir, astGrepName);
 
       if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
-        return binaryPath
+        return binaryPath;
       }
     } catch {
       // Platform-specific package not installed
     }
   }
 
-  if (process.platform === "darwin") {
-    const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
+  if (process.platform === 'darwin') {
+    const homebrewPaths = ['/opt/homebrew/bin/sg', '/usr/local/bin/sg'];
     for (const path of homebrewPaths) {
       if (existsSync(path) && isValidBinary(path)) {
-        return path
+        return path;
       }
     }
   }
 
-  return null
+  return null;
 }
 
 export function getSgCliPath(): string {
   if (resolvedCliPath !== null) {
-    return resolvedCliPath
+    return resolvedCliPath;
   }
 
-  const syncPath = findSgCliPathSync()
+  const syncPath = findSgCliPathSync();
   if (syncPath) {
-    resolvedCliPath = syncPath
-    return syncPath
+    resolvedCliPath = syncPath;
+    return syncPath;
   }
 
-  return "sg"
+  return 'sg';
 }
 
 export function setSgCliPath(path: string): void {
-  resolvedCliPath = path
+  resolvedCliPath = path;
 }
 
 // Re-export language constants
-export { CLI_LANGUAGES }
+export { CLI_LANGUAGES };
 
 // Defaults
-export const DEFAULT_TIMEOUT_MS = 300_000
-export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
-export const DEFAULT_MAX_MATCHES = 500
+export const DEFAULT_TIMEOUT_MS = 300_000;
+export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024;
+export const DEFAULT_MAX_MATCHES = 500;
 
 export const LANG_EXTENSIONS: Record<string, string[]> = {
-  bash: [".bash", ".sh", ".zsh", ".bats"],
-  c: [".c", ".h"],
-  cpp: [".cpp", ".cc", ".cxx", ".hpp", ".hxx", ".h"],
-  csharp: [".cs"],
-  css: [".css"],
-  elixir: [".ex", ".exs"],
-  go: [".go"],
-  haskell: [".hs", ".lhs"],
-  html: [".html", ".htm"],
-  java: [".java"],
-  javascript: [".js", ".jsx", ".mjs", ".cjs"],
-  json: [".json"],
-  kotlin: [".kt", ".kts"],
-  lua: [".lua"],
-  nix: [".nix"],
-  php: [".php"],
-  python: [".py", ".pyi"],
-  ruby: [".rb", ".rake"],
-  rust: [".rs"],
-  scala: [".scala", ".sc"],
-  solidity: [".sol"],
-  swift: [".swift"],
-  typescript: [".ts", ".cts", ".mts"],
-  tsx: [".tsx"],
-  yaml: [".yml", ".yaml"],
-}
+  bash: ['.bash', '.sh', '.zsh', '.bats'],
+  c: ['.c', '.h'],
+  cpp: ['.cpp', '.cc', '.cxx', '.hpp', '.hxx', '.h'],
+  csharp: ['.cs'],
+  css: ['.css'],
+  elixir: ['.ex', '.exs'],
+  go: ['.go'],
+  haskell: ['.hs', '.lhs'],
+  html: ['.html', '.htm'],
+  java: ['.java'],
+  javascript: ['.js', '.jsx', '.mjs', '.cjs'],
+  json: ['.json'],
+  kotlin: ['.kt', '.kts'],
+  lua: ['.lua'],
+  nix: ['.nix'],
+  php: ['.php'],
+  python: ['.py', '.pyi'],
+  ruby: ['.rb', '.rake'],
+  rust: ['.rs'],
+  scala: ['.scala', '.sc'],
+  solidity: ['.sol'],
+  swift: ['.swift'],
+  typescript: ['.ts', '.cts', '.mts'],
+  tsx: ['.tsx'],
+  yaml: ['.yml', '.yaml'],
+};
 
 export interface EnvironmentCheckResult {
   cli: {
-    available: boolean
-    path: string
-    error?: string
-  }
+    available: boolean;
+    path: string;
+    error?: string;
+  };
 }
 
 /**
@@ -155,54 +156,59 @@ export interface EnvironmentCheckResult {
  * Call this at startup to provide early feedback about missing dependencies.
  */
 export function checkEnvironment(): EnvironmentCheckResult {
-  const cliPath = getSgCliPath()
+  const cliPath = getSgCliPath();
   const result: EnvironmentCheckResult = {
     cli: {
       available: false,
       path: cliPath,
     },
-  }
+  };
 
   if (existsSync(cliPath)) {
-    result.cli.available = true
-  } else if (cliPath === "sg") {
+    result.cli.available = true;
+  } else if (cliPath === 'sg') {
     try {
-      const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
-        encoding: "utf-8",
-        timeout: 5000,
-      })
-      result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
+      const whichResult = spawnSync(
+        process.platform === 'win32' ? 'where' : 'which',
+        ['sg'],
+        {
+          encoding: 'utf-8',
+          timeout: 5000,
+        },
+      );
+      result.cli.available =
+        whichResult.status === 0 && !!whichResult.stdout?.trim();
       if (!result.cli.available) {
-        result.cli.error = "sg binary not found in PATH"
+        result.cli.error = 'sg binary not found in PATH';
       }
     } catch {
-      result.cli.error = "Failed to check sg availability"
+      result.cli.error = 'Failed to check sg availability';
     }
   } else {
-    result.cli.error = `Binary not found: ${cliPath}`
+    result.cli.error = `Binary not found: ${cliPath}`;
   }
 
-  return result
+  return result;
 }
 
 /**
  * Format environment check result as user-friendly message.
  */
 export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
-  const lines: string[] = ["ast-grep Environment Status:", ""]
+  const lines: string[] = ['ast-grep Environment Status:', ''];
 
   if (result.cli.available) {
-    lines.push(`✓ CLI: Available (${result.cli.path})`)
+    lines.push(`✓ CLI: Available (${result.cli.path})`);
   } else {
-    lines.push(`✗ CLI: Not available`)
+    lines.push(`✗ CLI: Not available`);
     if (result.cli.error) {
-      lines.push(`  Error: ${result.cli.error}`)
+      lines.push(`  Error: ${result.cli.error}`);
     }
-    lines.push(`  Install: bun add -D @ast-grep/cli`)
+    lines.push(`  Install: bun add -D @ast-grep/cli`);
   }
 
-  lines.push("")
-  lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
+  lines.push('');
+  lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`);
 
-  return lines.join("\n")
+  return lines.join('\n');
 }

+ 67 - 63
src/tools/ast-grep/downloader.ts

@@ -1,126 +1,130 @@
-import { existsSync, mkdirSync, chmodSync, unlinkSync } from "node:fs"
-import { join } from "node:path"
-import { homedir } from "node:os"
-import { createRequire } from "node:module"
-import { extractZip } from "../../utils"
+import { chmodSync, existsSync, mkdirSync, unlinkSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { extractZip } from '../../utils';
 
-const REPO = "ast-grep/ast-grep"
+const REPO = 'ast-grep/ast-grep';
 
 // IMPORTANT: Update this when bumping @ast-grep/cli in package.json
 // This is only used as fallback when @ast-grep/cli package.json cannot be read
-const DEFAULT_VERSION = "0.40.0"
+const DEFAULT_VERSION = '0.40.0';
 
 function getAstGrepVersion(): string {
   try {
-    const require = createRequire(import.meta.url)
-    const pkg = require("@ast-grep/cli/package.json")
-    return pkg.version
+    const require = createRequire(import.meta.url);
+    const pkg = require('@ast-grep/cli/package.json');
+    return pkg.version;
   } catch {
-    return DEFAULT_VERSION
+    return DEFAULT_VERSION;
   }
 }
 
 interface PlatformInfo {
-  arch: string
-  os: string
+  arch: string;
+  os: string;
 }
 
 const PLATFORM_MAP: Record<string, PlatformInfo> = {
-  "darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
-  "darwin-x64": { arch: "x86_64", os: "apple-darwin" },
-  "linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
-  "linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
-  "win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
-  "win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
-  "win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
-}
+  'darwin-arm64': { arch: 'aarch64', os: 'apple-darwin' },
+  'darwin-x64': { arch: 'x86_64', os: 'apple-darwin' },
+  'linux-arm64': { arch: 'aarch64', os: 'unknown-linux-gnu' },
+  'linux-x64': { arch: 'x86_64', os: 'unknown-linux-gnu' },
+  'win32-x64': { arch: 'x86_64', os: 'pc-windows-msvc' },
+  'win32-arm64': { arch: 'aarch64', os: 'pc-windows-msvc' },
+  'win32-ia32': { arch: 'i686', os: 'pc-windows-msvc' },
+};
 
 export function getCacheDir(): string {
-  if (process.platform === "win32") {
-    const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
-    const base = localAppData || join(homedir(), "AppData", "Local")
-    return join(base, "oh-my-opencode-slim", "bin")
+  if (process.platform === 'win32') {
+    const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA;
+    const base = localAppData || join(homedir(), 'AppData', 'Local');
+    return join(base, 'oh-my-opencode-slim', 'bin');
   }
 
-  const xdgCache = process.env.XDG_CACHE_HOME
-  const base = xdgCache || join(homedir(), ".cache")
-  return join(base, "oh-my-opencode-slim", "bin")
+  const xdgCache = process.env.XDG_CACHE_HOME;
+  const base = xdgCache || join(homedir(), '.cache');
+  return join(base, 'oh-my-opencode-slim', 'bin');
 }
 
 export function getBinaryName(): string {
-  return process.platform === "win32" ? "sg.exe" : "sg"
+  return process.platform === 'win32' ? 'sg.exe' : 'sg';
 }
 
 export function getCachedBinaryPath(): string | null {
-  const binaryPath = join(getCacheDir(), getBinaryName())
-  return existsSync(binaryPath) ? binaryPath : null
+  const binaryPath = join(getCacheDir(), getBinaryName());
+  return existsSync(binaryPath) ? binaryPath : null;
 }
 
-export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
-  const platformKey = `${process.platform}-${process.arch}`
-  const platformInfo = PLATFORM_MAP[platformKey]
+export async function downloadAstGrep(
+  version: string = DEFAULT_VERSION,
+): Promise<string | null> {
+  const platformKey = `${process.platform}-${process.arch}`;
+  const platformInfo = PLATFORM_MAP[platformKey];
 
   if (!platformInfo) {
-    console.error(`[oh-my-opencode-slim] Unsupported platform for ast-grep: ${platformKey}`)
-    return null
+    console.error(
+      `[oh-my-opencode-slim] Unsupported platform for ast-grep: ${platformKey}`,
+    );
+    return null;
   }
 
-  const cacheDir = getCacheDir()
-  const binaryName = getBinaryName()
-  const binaryPath = join(cacheDir, binaryName)
+  const cacheDir = getCacheDir();
+  const binaryName = getBinaryName();
+  const binaryPath = join(cacheDir, binaryName);
 
   if (existsSync(binaryPath)) {
-    return binaryPath
+    return binaryPath;
   }
 
-  const { arch, os } = platformInfo
-  const assetName = `app-${arch}-${os}.zip`
-  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
+  const { arch, os } = platformInfo;
+  const assetName = `app-${arch}-${os}.zip`;
+  const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`;
 
-  console.log(`[oh-my-opencode-slim] Downloading ast-grep binary...`)
+  console.log(`[oh-my-opencode-slim] Downloading ast-grep binary...`);
 
   try {
     if (!existsSync(cacheDir)) {
-      mkdirSync(cacheDir, { recursive: true })
+      mkdirSync(cacheDir, { recursive: true });
     }
 
-    const response = await fetch(downloadUrl, { redirect: "follow" })
+    const response = await fetch(downloadUrl, { redirect: 'follow' });
 
     if (!response.ok) {
-      throw new Error(`HTTP ${response.status}: ${response.statusText}`)
+      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
     }
 
-    const archivePath = join(cacheDir, assetName)
-    const arrayBuffer = await response.arrayBuffer()
-    await Bun.write(archivePath, arrayBuffer)
+    const archivePath = join(cacheDir, assetName);
+    const arrayBuffer = await response.arrayBuffer();
+    await Bun.write(archivePath, arrayBuffer);
 
-    await extractZip(archivePath, cacheDir)
+    await extractZip(archivePath, cacheDir);
 
     if (existsSync(archivePath)) {
-      unlinkSync(archivePath)
+      unlinkSync(archivePath);
     }
 
-    if (process.platform !== "win32" && existsSync(binaryPath)) {
-      chmodSync(binaryPath, 0o755)
+    if (process.platform !== 'win32' && existsSync(binaryPath)) {
+      chmodSync(binaryPath, 0o755);
     }
 
-    console.log(`[oh-my-opencode-slim] ast-grep binary ready.`)
+    console.log(`[oh-my-opencode-slim] ast-grep binary ready.`);
 
-    return binaryPath
+    return binaryPath;
   } catch (err) {
     console.error(
-      `[oh-my-opencode-slim] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
-    )
-    return null
+      `[oh-my-opencode-slim] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`,
+    );
+    return null;
   }
 }
 
 export async function ensureAstGrepBinary(): Promise<string | null> {
-  const cachedPath = getCachedBinaryPath()
+  const cachedPath = getCachedBinaryPath();
   if (cachedPath) {
-    return cachedPath
+    return cachedPath;
   }
 
-  const version = getAstGrepVersion()
-  return downloadAstGrep(version)
+  const version = getAstGrepVersion();
+  return downloadAstGrep(version);
 }

+ 19 - 10
src/tools/ast-grep/index.ts

@@ -1,15 +1,24 @@
-import type { ToolDefinition } from "@opencode-ai/plugin"
-import { ast_grep_search, ast_grep_replace } from "./tools"
+import type { ToolDefinition } from '@opencode-ai/plugin';
+import { ast_grep_replace, ast_grep_search } from './tools';
 
 export const builtinTools: Record<string, ToolDefinition> = {
   ast_grep_search,
   ast_grep_replace,
-}
+};
 
-export { ast_grep_search, ast_grep_replace }
-export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
-export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
-export { checkEnvironment, formatEnvironmentCheck } from "./constants"
-export type { EnvironmentCheckResult } from "./constants"
-export { CLI_LANGUAGES } from "./types"
-export type { CliLanguage, SgResult, CliMatch } from "./types"
+export { ast_grep_search, ast_grep_replace };
+export {
+  ensureCliAvailable,
+  getAstGrepPath,
+  isCliAvailable,
+  startBackgroundInit,
+} from './cli';
+export type { EnvironmentCheckResult } from './constants';
+export { checkEnvironment, formatEnvironmentCheck } from './constants';
+export {
+  ensureAstGrepBinary,
+  getCacheDir,
+  getCachedBinaryPath,
+} from './downloader';
+export type { CliLanguage, CliMatch, SgResult } from './types';
+export { CLI_LANGUAGES } from './types';

+ 57 - 40
src/tools/ast-grep/tools.ts

@@ -1,26 +1,34 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-import { CLI_LANGUAGES } from "./types"
-import { runSg } from "./cli"
-import { formatSearchResult, formatReplaceResult, getEmptyResultHint } from "./utils"
-import type { CliLanguage } from "./types"
+import { type ToolDefinition, tool } from '@opencode-ai/plugin/tool';
+import { runSg } from './cli';
+import type { CliLanguage } from './types';
+import { CLI_LANGUAGES } from './types';
+import {
+  formatReplaceResult,
+  formatSearchResult,
+  getEmptyResultHint,
+} from './utils';
 
 function showOutputToUser(context: unknown, output: string): void {
-  const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
-  ctx.metadata?.({ metadata: { output } })
+  const ctx = context as {
+    metadata?: (input: { metadata: { output: string } }) => void;
+  };
+  ctx.metadata?.({ metadata: { output } });
 }
 
 export const ast_grep_search: ToolDefinition = tool({
   description:
-    "Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
-    "Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
-    "IMPORTANT: Patterns must be complete AST nodes (valid code). " +
+    'Search code patterns across filesystem using AST-aware matching. Supports 25 languages. ' +
+    'Use meta-variables: $VAR (single node), $$$ (multiple nodes). ' +
+    'IMPORTANT: Patterns must be complete AST nodes (valid code). ' +
     "For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
     "Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
   args: {
     pattern: tool.schema
       .string()
-      .describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
-    lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
+      .describe(
+        'AST pattern with meta-variables ($VAR, $$$). Must be complete AST node.',
+      ),
+    lang: tool.schema.enum(CLI_LANGUAGES).describe('Target language'),
     paths: tool.schema
       .array(tool.schema.string())
       .optional()
@@ -28,8 +36,11 @@ export const ast_grep_search: ToolDefinition = tool({
     globs: tool.schema
       .array(tool.schema.string())
       .optional()
-      .describe("Include/exclude globs (prefix ! to exclude)"),
-    context: tool.schema.number().optional().describe("Context lines around match"),
+      .describe('Include/exclude globs (prefix ! to exclude)'),
+    context: tool.schema
+      .number()
+      .optional()
+      .describe('Context lines around match'),
   },
   execute: async (args, context) => {
     try {
@@ -39,44 +50,50 @@ export const ast_grep_search: ToolDefinition = tool({
         paths: args.paths,
         globs: args.globs,
         context: args.context,
-      })
+      });
 
-      let output = formatSearchResult(result)
+      let output = formatSearchResult(result);
 
       if (result.matches.length === 0 && !result.error) {
-        const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
+        const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage);
         if (hint) {
-          output += `\n\n${hint}`
+          output += `\n\n${hint}`;
         }
       }
 
-      showOutputToUser(context, output)
-      return output
+      showOutputToUser(context, output);
+      return output;
     } catch (e) {
-      const output = `Error: ${e instanceof Error ? e.message : String(e)}`
-      showOutputToUser(context, output)
-      return output
+      const output = `Error: ${e instanceof Error ? e.message : String(e)}`;
+      showOutputToUser(context, output);
+      return output;
     }
   },
-})
+});
 
 export const ast_grep_replace: ToolDefinition = tool({
   description:
-    "Replace code patterns across filesystem with AST-aware rewriting. " +
-    "Dry-run by default. Use meta-variables in rewrite to preserve matched content. " +
+    'Replace code patterns across filesystem with AST-aware rewriting. ' +
+    'Dry-run by default. Use meta-variables in rewrite to preserve matched content. ' +
     "Example: pattern='console.log($MSG)' rewrite='logger.info($MSG)'",
   args: {
-    pattern: tool.schema.string().describe("AST pattern to match"),
+    pattern: tool.schema.string().describe('AST pattern to match'),
     rewrite: tool.schema
       .string()
-      .describe("Replacement pattern (can use $VAR from pattern)"),
-    lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
-    paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search"),
-    globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs"),
+      .describe('Replacement pattern (can use $VAR from pattern)'),
+    lang: tool.schema.enum(CLI_LANGUAGES).describe('Target language'),
+    paths: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe('Paths to search'),
+    globs: tool.schema
+      .array(tool.schema.string())
+      .optional()
+      .describe('Include/exclude globs'),
     dryRun: tool.schema
       .boolean()
       .optional()
-      .describe("Preview changes without applying (default: true)"),
+      .describe('Preview changes without applying (default: true)'),
   },
   execute: async (args, context) => {
     try {
@@ -87,14 +104,14 @@ export const ast_grep_replace: ToolDefinition = tool({
         paths: args.paths,
         globs: args.globs,
         updateAll: args.dryRun === false,
-      })
-      const output = formatReplaceResult(result, args.dryRun !== false)
-      showOutputToUser(context, output)
-      return output
+      });
+      const output = formatReplaceResult(result, args.dryRun !== false);
+      showOutputToUser(context, output);
+      return output;
     } catch (e) {
-      const output = `Error: ${e instanceof Error ? e.message : String(e)}`
-      showOutputToUser(context, output)
-      return output
+      const output = `Error: ${e instanceof Error ? e.message : String(e)}`;
+      showOutputToUser(context, output);
+      return output;
     }
   },
-})
+});

+ 41 - 41
src/tools/ast-grep/types.ts

@@ -1,51 +1,51 @@
 // CLI supported languages (25 total)
 export const CLI_LANGUAGES = [
-  "bash",
-  "c",
-  "cpp",
-  "csharp",
-  "css",
-  "elixir",
-  "go",
-  "haskell",
-  "html",
-  "java",
-  "javascript",
-  "json",
-  "kotlin",
-  "lua",
-  "nix",
-  "php",
-  "python",
-  "ruby",
-  "rust",
-  "scala",
-  "solidity",
-  "swift",
-  "typescript",
-  "tsx",
-  "yaml",
-] as const
+  'bash',
+  'c',
+  'cpp',
+  'csharp',
+  'css',
+  'elixir',
+  'go',
+  'haskell',
+  'html',
+  'java',
+  'javascript',
+  'json',
+  'kotlin',
+  'lua',
+  'nix',
+  'php',
+  'python',
+  'ruby',
+  'rust',
+  'scala',
+  'solidity',
+  'swift',
+  'typescript',
+  'tsx',
+  'yaml',
+] as const;
 
-export type CliLanguage = (typeof CLI_LANGUAGES)[number]
+export type CliLanguage = (typeof CLI_LANGUAGES)[number];
 
 export interface CliMatch {
-  file: string
+  file: string;
   range: {
-    byteOffset: { start: number; end: number }
-    start: { line: number; column: number }
-    end: { line: number; column: number }
-  }
-  lines: string
-  text: string
-  replacement?: string
-  language: string
+    byteOffset: { start: number; end: number };
+    start: { line: number; column: number };
+    end: { line: number; column: number };
+  };
+  lines: string;
+  text: string;
+  replacement?: string;
+  language: string;
 }
 
 export interface SgResult {
-  matches: CliMatch[]
-  totalMatches: number
-  truncated: boolean
-  truncatedReason?: "timeout" | "max_output_bytes" | "max_matches"
-  error?: string
+  matches: CliMatch[];
+  totalMatches: number;
+  truncated: boolean;
+  truncatedReason?: 'timeout' | 'max_output_bytes' | 'max_matches';
+  error?: string;
 }

+ 67 - 48
src/tools/ast-grep/utils.ts

@@ -1,107 +1,126 @@
-import type { SgResult, CliLanguage } from "./types"
+import type { CliLanguage, SgResult } from './types';
 
 export function formatSearchResult(result: SgResult): string {
   if (result.error) {
-    return `Error: ${result.error}`
+    return `Error: ${result.error}`;
   }
 
   if (result.matches.length === 0) {
-    return "No matches found."
+    return 'No matches found.';
   }
 
-  const lines: string[] = []
+  const lines: string[] = [];
 
   // Group matches by file
-  const byFile = new Map<string, typeof result.matches>()
+  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 existing = byFile.get(match.file) || [];
+    existing.push(match);
+    byFile.set(match.file, existing);
   }
 
   for (const [file, matches] of byFile) {
-    lines.push(`\n${file}:`)
+    lines.push(`\n${file}:`);
     for (const match of matches) {
-      const startLine = match.range.start.line + 1
-      const text = match.text.length > 100 ? match.text.substring(0, 100) + "..." : match.text
-      lines.push(`  ${startLine}: ${text.replace(/\n/g, "\\n")}`)
+      const startLine = match.range.start.line + 1;
+      const text =
+        match.text.length > 100
+          ? `${match.text.substring(0, 100)}...`
+          : match.text;
+      lines.push(`  ${startLine}: ${text.replace(/\n/g, '\\n')}`);
     }
   }
 
-  const fileCount = byFile.size
-  const summary = `Found ${result.totalMatches} matches in ${fileCount} files`
+  const fileCount = byFile.size;
+  const summary = `Found ${result.totalMatches} matches in ${fileCount} files`;
   if (result.truncated) {
-    lines.push(`\n${summary} (output truncated: ${result.truncatedReason})`)
+    lines.push(`\n${summary} (output truncated: ${result.truncatedReason})`);
   } else {
-    lines.push(`\n${summary}`)
+    lines.push(`\n${summary}`);
   }
 
-  return lines.join("\n")
+  return lines.join('\n');
 }
 
-export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
+export function formatReplaceResult(
+  result: SgResult,
+  isDryRun: boolean,
+): string {
   if (result.error) {
-    return `Error: ${result.error}`
+    return `Error: ${result.error}`;
   }
 
   if (result.matches.length === 0) {
-    return "No matches found for replacement."
+    return 'No matches found for replacement.';
   }
 
-  const lines: string[] = []
-  const mode = isDryRun ? "[DRY RUN]" : "[APPLIED]"
+  const lines: string[] = [];
+  const mode = isDryRun ? '[DRY RUN]' : '[APPLIED]';
 
   // Group by file
-  const byFile = new Map<string, typeof result.matches>()
+  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 existing = byFile.get(match.file) || [];
+    existing.push(match);
+    byFile.set(match.file, existing);
   }
 
   for (const [file, matches] of byFile) {
-    lines.push(`\n${file}:`)
+    lines.push(`\n${file}:`);
     for (const match of matches) {
-      const startLine = match.range.start.line + 1
-      const original = match.text.length > 60 ? match.text.substring(0, 60) + "..." : match.text
+      const startLine = match.range.start.line + 1;
+      const original =
+        match.text.length > 60
+          ? `${match.text.substring(0, 60)}...`
+          : match.text;
       const replacement = match.replacement
         ? match.replacement.length > 60
-          ? match.replacement.substring(0, 60) + "..."
+          ? `${match.replacement.substring(0, 60)}...`
           : match.replacement
-        : "[no replacement]"
-      lines.push(`  ${startLine}: "${original.replace(/\n/g, "\\n")}" → "${replacement.replace(/\n/g, "\\n")}"`)
+        : '[no replacement]';
+      lines.push(
+        `  ${startLine}: "${original.replace(/\n/g, '\\n')}" → "${replacement.replace(/\n/g, '\\n')}"`,
+      );
     }
   }
 
-  const fileCount = byFile.size
-  lines.push(`\n${mode} ${result.totalMatches} replacements in ${fileCount} files`)
+  const fileCount = byFile.size;
+  lines.push(
+    `\n${mode} ${result.totalMatches} replacements in ${fileCount} files`,
+  );
 
   if (isDryRun) {
-    lines.push("\nTo apply changes, run with dryRun=false")
+    lines.push('\nTo apply changes, run with dryRun=false');
   }
 
-  return lines.join("\n")
+  return lines.join('\n');
 }
 
-export function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
-  const src = pattern.trim()
+export function getEmptyResultHint(
+  pattern: string,
+  lang: CliLanguage,
+): string | null {
+  const src = pattern.trim();
 
-  if (lang === "python") {
-    if (src.startsWith("class ") && src.endsWith(":")) {
-      const withoutColon = src.slice(0, -1)
-      return `Hint: Remove trailing colon. Try: "${withoutColon}"`
+  if (lang === 'python') {
+    if (src.startsWith('class ') && src.endsWith(':')) {
+      const withoutColon = src.slice(0, -1);
+      return `Hint: Remove trailing colon. Try: "${withoutColon}"`;
     }
-    if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
-      const withoutColon = src.slice(0, -1)
-      return `Hint: Remove trailing colon. Try: "${withoutColon}"`
+    if (
+      (src.startsWith('def ') || src.startsWith('async def ')) &&
+      src.endsWith(':')
+    ) {
+      const withoutColon = src.slice(0, -1);
+      return `Hint: Remove trailing colon. Try: "${withoutColon}"`;
     }
   }
 
-  if (["javascript", "typescript", "tsx"].includes(lang)) {
+  if (['javascript', 'typescript', 'tsx'].includes(lang)) {
     if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
-      return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
+      return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`;
     }
   }
 
-  return null
+  return null;
 }

+ 423 - 307
src/tools/background.test.ts

@@ -1,33 +1,33 @@
-import { describe, expect, test, beforeEach, mock, spyOn, afterEach } from "bun:test";
-import { 
-  createBackgroundTools, 
-  resolveSessionId, 
-  createSession, 
-  sendPrompt, 
-  pollSession, 
-  extractResponseText 
-} from "./background.ts";
-import { BackgroundTaskManager } from "../background/background-manager";
-import type { PluginInput } from "@opencode-ai/plugin";
-import { 
-  POLL_INTERVAL_MS, 
-  MAX_POLL_TIME_MS, 
-  STABLE_POLLS_THRESHOLD 
-} from "../config";
+import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { BackgroundTaskManager } from '../background/background-manager';
+import { MAX_POLL_TIME_MS, STABLE_POLLS_THRESHOLD } from '../config';
+import {
+  createBackgroundTools,
+  createSession,
+  extractResponseText,
+  pollSession,
+  resolveSessionId,
+  sendPrompt,
+} from './background.ts';
 
 // Mock the PluginInput context
 function createMockContext(overrides: any = {}) {
   return {
     client: {
       session: {
-        create: mock(async () => ({ data: { id: "new-session-id" } })),
-        get: mock(async () => ({ data: { id: "existing-session-id", directory: "/parent/dir" } })),
-        status: mock(async () => ({ data: { "new-session-id": { type: "idle" } } })),
+        create: mock(async () => ({ data: { id: 'new-session-id' } })),
+        get: mock(async () => ({
+          data: { id: 'existing-session-id', directory: '/parent/dir' },
+        })),
+        status: mock(async () => ({
+          data: { 'new-session-id': { type: 'idle' } },
+        })),
         messages: mock(async () => ({ data: [] })),
         prompt: mock(async () => ({})),
       },
     },
-    directory: "/current/dir",
+    directory: '/current/dir',
     ...overrides,
   } as unknown as PluginInput;
 }
@@ -38,17 +38,17 @@ function createMockManager() {
   return {
     launch: mock(async (opts: any) => {
       const task = {
-        id: "bg_123",
+        id: 'bg_123',
         agent: opts.agent,
         prompt: opts.prompt,
         description: opts.description,
-        status: "running",
+        status: 'running',
         startedAt: new Date(),
       };
       tasks.set(task.id, task);
       return task;
     }),
-    getResult: mock(async (id: string, block?: boolean, timeout?: number) => {
+    getResult: mock(async (id: string, _block?: boolean, _timeout?: number) => {
       return tasks.get(id) || null;
     }),
     cancel: mock((id?: string) => {
@@ -66,7 +66,7 @@ function createMockManager() {
   } as unknown as BackgroundTaskManager;
 }
 
-describe("Background Tools", () => {
+describe('Background Tools', () => {
   let ctx: PluginInput;
   let manager: BackgroundTaskManager;
   let tools: any;
@@ -77,399 +77,515 @@ describe("Background Tools", () => {
     tools = createBackgroundTools(ctx, manager);
   });
 
-  describe("background_task", () => {
-    test("launches a background task in async mode", async () => {
+  describe('background_task', () => {
+    test('launches a background task in async mode', async () => {
       const result = await tools.background_task.execute(
         {
-          agent: "explorer",
-          prompt: "find files",
-          description: "finding files",
+          agent: 'explorer',
+          prompt: 'find files',
+          description: 'finding files',
           sync: false,
         },
-        { sessionID: "parent-session-id" }
+        { sessionID: 'parent-session-id' },
       );
 
       expect(manager.launch).toHaveBeenCalledWith({
-        agent: "explorer",
-        prompt: "find files",
-        description: "finding files",
-        parentSessionId: "parent-session-id",
+        agent: 'explorer',
+        prompt: 'find files',
+        description: 'finding files',
+        parentSessionId: 'parent-session-id',
       });
-      expect(result).toContain("Background task launched");
-      expect(result).toContain("Task ID: bg_123");
+      expect(result).toContain('Background task launched');
+      expect(result).toContain('Task ID: bg_123');
     });
 
-    test("executes a task in sync mode", async () => {
+    test('executes a task in sync mode', async () => {
       // Setup mock responses for sync execution
       (ctx.client.session.messages as any).mockImplementation(async () => ({
         data: [
-          { info: { role: "assistant" }, parts: [{ type: "text", text: "Task result" }] },
+          {
+            info: { role: 'assistant' },
+            parts: [{ type: 'text', text: 'Task result' }],
+          },
         ],
       }));
 
       const result = await tools.background_task.execute(
         {
-          agent: "explorer",
-          prompt: "find files",
-          description: "finding files",
+          agent: 'explorer',
+          prompt: 'find files',
+          description: 'finding files',
           sync: true,
         },
-        { sessionID: "parent-session-id", abort: new AbortController().signal }
+        { sessionID: 'parent-session-id', abort: new AbortController().signal },
       );
 
       expect(ctx.client.session.create).toHaveBeenCalled();
       expect(ctx.client.session.prompt).toHaveBeenCalled();
-      expect(result).toContain("Task result");
-      expect(result).toContain("session_id: new-session-id");
+      expect(result).toContain('Task result');
+      expect(result).toContain('session_id: new-session-id');
     });
 
-    test("returns error message if session resolution fails", async () => {
-        (ctx.client.session.get as any).mockResolvedValue({ error: "Get failed" });
-        const result = await tools.background_task.execute(
-          { agent: "explorer", prompt: "test", description: "test", sync: true, session_id: "invalid" },
-          { sessionID: "p1" } as any
-        );
-        expect(result).toContain("Error: Failed to get session: Get failed");
+    test('returns error message if session resolution fails', async () => {
+      (ctx.client.session.get as any).mockResolvedValue({
+        error: 'Get failed',
+      });
+      const result = await tools.background_task.execute(
+        {
+          agent: 'explorer',
+          prompt: 'test',
+          description: 'test',
+          sync: true,
+          session_id: 'invalid',
+        },
+        { sessionID: 'p1' } as any,
+      );
+      expect(result).toContain('Error: Failed to get session: Get failed');
     });
 
-    test("returns error message if prompt sending fails", async () => {
-        (ctx.client.session.prompt as any).mockRejectedValue(new Error("Prompt failed"));
-        const result = await tools.background_task.execute(
-          { agent: "explorer", prompt: "test", description: "test", sync: true },
-          { sessionID: "p1", abort: new AbortController().signal } as any
-        );
-        expect(result).toContain("Error: Failed to send prompt: Prompt failed");
-        expect(result).toContain("<task_metadata>");
+    test('returns error message if prompt sending fails', async () => {
+      (ctx.client.session.prompt as any).mockRejectedValue(
+        new Error('Prompt failed'),
+      );
+      const result = await tools.background_task.execute(
+        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
+        { sessionID: 'p1', abort: new AbortController().signal } as any,
+      );
+      expect(result).toContain('Error: Failed to send prompt: Prompt failed');
+      expect(result).toContain('<task_metadata>');
     });
 
-    test("handles task abort in sync mode", async () => {
-        (ctx.client.session.status as any).mockImplementation(async () => {
-            return { data: { "new-session-id": { type: "busy" } } };
-        });
-        const controller = new AbortController();
-        
-        // Trigger abort after a short delay
-        setTimeout(() => controller.abort(), 100);
+    test('handles task abort in sync mode', async () => {
+      (ctx.client.session.status as any).mockImplementation(async () => {
+        return { data: { 'new-session-id': { type: 'busy' } } };
+      });
+      const controller = new AbortController();
 
-        const result = await tools.background_task.execute(
-            { agent: "explorer", prompt: "test", description: "test", sync: true },
-            { sessionID: "p1", abort: controller.signal } as any
-        );
-        expect(result).toContain("Task aborted.");
-    });
-
-    test("handles timeout in sync mode", async () => {
-        // Mock pollSession to return timeout
-        // We can't easily mock pollSession if we are testing through background_task.execute
-        // because it's an internal function. 
-        // But since we exported it, we could try to mock it if we use a different approach,
-        // or just mock the dependencies of pollSession to force a timeout.
-        
-        // Actually, we can just mock Date.now inside the test.
-        const originalNow = Date.now;
-        let calls = 0;
-        Date.now = () => {
-            calls++;
-            if (calls > 5) return originalNow() + MAX_POLL_TIME_MS + 1000;
-            return originalNow();
-        };
-
-        try {
-            const result = await tools.background_task.execute(
-                { agent: "explorer", prompt: "test", description: "test", sync: true },
-                { sessionID: "p1", abort: new AbortController().signal } as any
-            );
-            expect(result).toContain("Error: Agent timed out");
-        } finally {
-            Date.now = originalNow;
-        }
+      // Trigger abort after a short delay
+      setTimeout(() => controller.abort(), 100);
+
+      const result = await tools.background_task.execute(
+        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
+        { sessionID: 'p1', abort: controller.signal } as any,
+      );
+      expect(result).toContain('Task aborted.');
     });
 
-    test("returns error if pollSession fails", async () => {
-        // Force pollSession to return error by mocking status to fail
-        (ctx.client.session.status as any).mockResolvedValue({ error: "Poll failed" });
+    test('handles timeout in sync mode', async () => {
+      // Mock pollSession to return timeout
+      // We can't easily mock pollSession if we are testing through background_task.execute
+      // because it's an internal function.
+      // But since we exported it, we could try to mock it if we use a different approach,
+      // or just mock the dependencies of pollSession to force a timeout.
+
+      // Actually, we can just mock Date.now inside the test.
+      const originalNow = Date.now;
+      let calls = 0;
+      Date.now = () => {
+        calls++;
+        if (calls > 5) return originalNow() + MAX_POLL_TIME_MS + 1000;
+        return originalNow();
+      };
+
+      try {
         const result = await tools.background_task.execute(
-          { agent: "explorer", prompt: "test", description: "test", sync: true },
-          { sessionID: "p1", abort: new AbortController().signal } as any
+          {
+            agent: 'explorer',
+            prompt: 'test',
+            description: 'test',
+            sync: true,
+          },
+          { sessionID: 'p1', abort: new AbortController().signal } as any,
         );
-        expect(result).toContain("Error: Failed to get session status: Poll failed");
+        expect(result).toContain('Error: Agent timed out');
+      } finally {
+        Date.now = originalNow;
+      }
+    });
+
+    test('returns error if pollSession fails', async () => {
+      // Force pollSession to return error by mocking status to fail
+      (ctx.client.session.status as any).mockResolvedValue({
+        error: 'Poll failed',
+      });
+      const result = await tools.background_task.execute(
+        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
+        { sessionID: 'p1', abort: new AbortController().signal } as any,
+      );
+      expect(result).toContain(
+        'Error: Failed to get session status: Poll failed',
+      );
     });
 
-    test("returns error if messages retrieval fails after polling", async () => {
-        (ctx.client.session.messages as any).mockResolvedValue({ error: "Messages failed" });
-        // First few calls to status/messages in pollSession need to succeed
-        let calls = 0;
-        (ctx.client.session.messages as any).mockImplementation(async () => {
-            calls++;
-            if (calls <= STABLE_POLLS_THRESHOLD + 1) return { data: [{}] }; // Stable count for polling
-            return { error: "Messages failed" }; // Fail after polling
-        });
+    test('returns error if messages retrieval fails after polling', async () => {
+      (ctx.client.session.messages as any).mockResolvedValue({
+        error: 'Messages failed',
+      });
+      // First few calls to status/messages in pollSession need to succeed
+      let calls = 0;
+      (ctx.client.session.messages as any).mockImplementation(async () => {
+        calls++;
+        if (calls <= STABLE_POLLS_THRESHOLD + 1) return { data: [{}] }; // Stable count for polling
+        return { error: 'Messages failed' }; // Fail after polling
+      });
 
-        const result = await tools.background_task.execute(
-          { agent: "explorer", prompt: "test", description: "test", sync: true },
-          { sessionID: "p1", abort: new AbortController().signal } as any
-        );
-        expect(result).toContain("Error: Failed to get messages: Messages failed");
+      const result = await tools.background_task.execute(
+        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
+        { sessionID: 'p1', abort: new AbortController().signal } as any,
+      );
+      expect(result).toContain(
+        'Error: Failed to get messages: Messages failed',
+      );
     });
 
-    test("returns error if no response text extracted", async () => {
-        // Return only user messages so extractResponseText returns empty
-        (ctx.client.session.messages as any).mockResolvedValue({ data: [{ info: { role: "user" }, parts: [{ type: "text", text: "hi" }] }] });
-        const result = await tools.background_task.execute(
-          { agent: "explorer", prompt: "test", description: "test", sync: true },
-          { sessionID: "p1", abort: new AbortController().signal } as any
-        );
-        expect(result).toContain("Error: No response from agent.");
+    test('returns error if no response text extracted', async () => {
+      // Return only user messages so extractResponseText returns empty
+      (ctx.client.session.messages as any).mockResolvedValue({
+        data: [
+          { info: { role: 'user' }, parts: [{ type: 'text', text: 'hi' }] },
+        ],
+      });
+      const result = await tools.background_task.execute(
+        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
+        { sessionID: 'p1', abort: new AbortController().signal } as any,
+      );
+      expect(result).toContain('Error: No response from agent.');
     });
 
-    test("throws error if sessionID is missing in toolContext", async () => {
-        await expect(tools.background_task.execute(
-            { agent: "explorer", prompt: "test", description: "test" },
-            {} as any
-        )).rejects.toThrow("Invalid toolContext: missing sessionID");
+    test('throws error if sessionID is missing in toolContext', async () => {
+      await expect(
+        tools.background_task.execute(
+          { agent: 'explorer', prompt: 'test', description: 'test' },
+          {} as any,
+        ),
+      ).rejects.toThrow('Invalid toolContext: missing sessionID');
     });
   });
 
-  describe("background_output", () => {
-    test("returns task output", async () => {
+  describe('background_output', () => {
+    test('returns task output', async () => {
       const task = {
-        id: "bg_123",
-        description: "test task",
-        status: "completed",
+        id: 'bg_123',
+        description: 'test task',
+        status: 'completed',
         startedAt: new Date(Date.now() - 5000),
         completedAt: new Date(),
-        result: "Success!",
+        result: 'Success!',
       };
       (manager.getResult as any).mockResolvedValue(task);
 
-      const result = await tools.background_output.execute({ task_id: "bg_123" });
+      const result = await tools.background_output.execute({
+        task_id: 'bg_123',
+      });
 
-      expect(result).toContain("Task: bg_123");
-      expect(result).toContain("Status: completed");
-      expect(result).toContain("Success!");
+      expect(result).toContain('Task: bg_123');
+      expect(result).toContain('Status: completed');
+      expect(result).toContain('Success!');
     });
 
-    test("returns error if task not found", async () => {
+    test('returns error if task not found', async () => {
       (manager.getResult as any).mockResolvedValue(null);
-      const result = await tools.background_output.execute({ task_id: "non-existent" });
-      expect(result).toBe("Task not found: non-existent");
-    });
-
-    test("shows running status if not completed", async () => {
-        const task = {
-            id: "bg_123",
-            description: "test task",
-            status: "running",
-            startedAt: new Date(),
-        };
-        (manager.getResult as any).mockResolvedValue(task);
-  
-        const result = await tools.background_output.execute({ task_id: "bg_123" });
-        expect(result).toContain("Status: running");
-        expect(result).toContain("(Task still running)");
-    });
-
-    test("shows error if task failed", async () => {
-        const task = {
-            id: "bg_123",
-            description: "test task",
-            status: "failed",
-            startedAt: new Date(),
-            error: "Something went wrong",
-        };
-        (manager.getResult as any).mockResolvedValue(task);
-  
-        const result = await tools.background_output.execute({ task_id: "bg_123" });
-        expect(result).toContain("Status: failed");
-        expect(result).toContain("Error: Something went wrong");
+      const result = await tools.background_output.execute({
+        task_id: 'non-existent',
+      });
+      expect(result).toBe('Task not found: non-existent');
+    });
+
+    test('shows running status if not completed', async () => {
+      const task = {
+        id: 'bg_123',
+        description: 'test task',
+        status: 'running',
+        startedAt: new Date(),
+      };
+      (manager.getResult as any).mockResolvedValue(task);
+
+      const result = await tools.background_output.execute({
+        task_id: 'bg_123',
+      });
+      expect(result).toContain('Status: running');
+      expect(result).toContain('(Task still running)');
+    });
+
+    test('shows error if task failed', async () => {
+      const task = {
+        id: 'bg_123',
+        description: 'test task',
+        status: 'failed',
+        startedAt: new Date(),
+        error: 'Something went wrong',
+      };
+      (manager.getResult as any).mockResolvedValue(task);
+
+      const result = await tools.background_output.execute({
+        task_id: 'bg_123',
+      });
+      expect(result).toContain('Status: failed');
+      expect(result).toContain('Error: Something went wrong');
     });
   });
 
-  describe("background_cancel", () => {
-    test("cancels all tasks", async () => {
+  describe('background_cancel', () => {
+    test('cancels all tasks', async () => {
       (manager.cancel as any).mockReturnValue(5);
       const result = await tools.background_cancel.execute({ all: true });
-      expect(result).toBe("Cancelled 5 running task(s).");
+      expect(result).toBe('Cancelled 5 running task(s).');
       expect(manager.cancel).toHaveBeenCalledWith();
     });
 
-    test("cancels specific task", async () => {
+    test('cancels specific task', async () => {
       (manager.cancel as any).mockReturnValue(1);
-      const result = await tools.background_cancel.execute({ task_id: "bg_123" });
-      expect(result).toBe("Cancelled task bg_123.");
-      expect(manager.cancel).toHaveBeenCalledWith("bg_123");
+      const result = await tools.background_cancel.execute({
+        task_id: 'bg_123',
+      });
+      expect(result).toBe('Cancelled task bg_123.');
+      expect(manager.cancel).toHaveBeenCalledWith('bg_123');
     });
 
-    test("returns not found for specific task", async () => {
-        (manager.cancel as any).mockReturnValue(0);
-        const result = await tools.background_cancel.execute({ task_id: "bg_123" });
-        expect(result).toBe("Task bg_123 not found or not running.");
+    test('returns not found for specific task', async () => {
+      (manager.cancel as any).mockReturnValue(0);
+      const result = await tools.background_cancel.execute({
+        task_id: 'bg_123',
+      });
+      expect(result).toBe('Task bg_123 not found or not running.');
     });
 
-    test("requires task_id or all", async () => {
-        const result = await tools.background_cancel.execute({});
-        expect(result).toBe("Specify task_id or use all=true.");
+    test('requires task_id or all', async () => {
+      const result = await tools.background_cancel.execute({});
+      expect(result).toBe('Specify task_id or use all=true.');
     });
   });
 
-  describe("resolveSessionId", () => {
-    test("validates and returns existing session ID", async () => {
-      const result = await resolveSessionId(ctx, { sessionID: "p1" } as any, "desc", "agent", undefined, "existing-id");
-      expect(ctx.client.session.get).toHaveBeenCalledWith({ path: { id: "existing-id" } });
-      expect(result.sessionID).toBe("existing-id");
-    });
-
-    test("returns error if existing session not found", async () => {
-      (ctx.client.session.get as any).mockResolvedValue({ error: "Not found" });
-      const result = await resolveSessionId(ctx, { sessionID: "p1" } as any, "desc", "agent", undefined, "invalid-id");
-      expect(result.error).toContain("Failed to get session");
+  describe('resolveSessionId', () => {
+    test('validates and returns existing session ID', async () => {
+      const result = await resolveSessionId(
+        ctx,
+        { sessionID: 'p1' } as any,
+        'desc',
+        'agent',
+        undefined,
+        'existing-id',
+      );
+      expect(ctx.client.session.get).toHaveBeenCalledWith({
+        path: { id: 'existing-id' },
+      });
+      expect(result.sessionID).toBe('existing-id');
+    });
+
+    test('returns error if existing session not found', async () => {
+      (ctx.client.session.get as any).mockResolvedValue({ error: 'Not found' });
+      const result = await resolveSessionId(
+        ctx,
+        { sessionID: 'p1' } as any,
+        'desc',
+        'agent',
+        undefined,
+        'invalid-id',
+      );
+      expect(result.error).toContain('Failed to get session');
     });
 
-    test("creates new session if no existing ID provided", async () => {
-      const result = await resolveSessionId(ctx, { sessionID: "p1" } as any, "desc", "agent");
+    test('creates new session if no existing ID provided', async () => {
+      const result = await resolveSessionId(
+        ctx,
+        { sessionID: 'p1' } as any,
+        'desc',
+        'agent',
+      );
       expect(ctx.client.session.create).toHaveBeenCalled();
-      expect(result.sessionID).toBe("new-session-id");
+      expect(result.sessionID).toBe('new-session-id');
     });
   });
 
-  describe("createSession", () => {
-    test("inherits parent directory", async () => {
-      (ctx.client.session.get as any).mockResolvedValue({ data: { directory: "/inherited/dir" } });
-      const result = await createSession(ctx, { sessionID: "parent-id" } as any, "desc", "agent");
-      
-      expect(ctx.client.session.create).toHaveBeenCalledWith(expect.objectContaining({
-        query: { directory: "/inherited/dir" }
-      }));
-      expect(result.sessionID).toBe("new-session-id");
+  describe('createSession', () => {
+    test('inherits parent directory', async () => {
+      (ctx.client.session.get as any).mockResolvedValue({
+        data: { directory: '/inherited/dir' },
+      });
+      const result = await createSession(
+        ctx,
+        { sessionID: 'parent-id' } as any,
+        'desc',
+        'agent',
+      );
+
+      expect(ctx.client.session.create).toHaveBeenCalledWith(
+        expect.objectContaining({
+          query: { directory: '/inherited/dir' },
+        }),
+      );
+      expect(result.sessionID).toBe('new-session-id');
     });
 
-    test("uses default directory if parent lookup fails", async () => {
-      (ctx.client.session.get as any).mockRejectedValue(new Error("Fail"));
-      const result = await createSession(ctx, { sessionID: "parent-id" } as any, "desc", "agent");
-      
-      expect(ctx.client.session.create).toHaveBeenCalledWith(expect.objectContaining({
-        query: { directory: "/current/dir" }
-      }));
+    test('uses default directory if parent lookup fails', async () => {
+      (ctx.client.session.get as any).mockRejectedValue(new Error('Fail'));
+      const _result = await createSession(
+        ctx,
+        { sessionID: 'parent-id' } as any,
+        'desc',
+        'agent',
+      );
+
+      expect(ctx.client.session.create).toHaveBeenCalledWith(
+        expect.objectContaining({
+          query: { directory: '/current/dir' },
+        }),
+      );
     });
 
-    test("respects tmux enabled delay", async () => {
-        const setTimeoutSpy = spyOn(global, "setTimeout");
-        await createSession(ctx, { sessionID: "p1" } as any, "desc", "agent", { enabled: true } as any);
-        expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
+    test('respects tmux enabled delay', async () => {
+      const setTimeoutSpy = spyOn(global, 'setTimeout');
+      await createSession(ctx, { sessionID: 'p1' } as any, 'desc', 'agent', {
+        enabled: true,
+      } as any);
+      expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
     });
   });
 
-  describe("sendPrompt", () => {
-    test("sends prompt with variant resolution", async () => {
+  describe('sendPrompt', () => {
+    test('sends prompt with variant resolution', async () => {
       const pluginConfig = {
         agents: {
-            agent: { variant: "pro" }
-        }
+          agent: { variant: 'pro' },
+        },
       } as any;
-      const result = await sendPrompt(ctx, "s1", "my prompt", "agent", pluginConfig);
-      expect(ctx.client.session.prompt).toHaveBeenCalledWith(expect.objectContaining({
-        body: expect.objectContaining({
-          agent: "agent",
-          variant: "pro"
-        })
-      }));
+      const result = await sendPrompt(
+        ctx,
+        's1',
+        'my prompt',
+        'agent',
+        pluginConfig,
+      );
+      expect(ctx.client.session.prompt).toHaveBeenCalledWith(
+        expect.objectContaining({
+          body: expect.objectContaining({
+            agent: 'agent',
+            variant: 'pro',
+          }),
+        }),
+      );
       expect(result.error).toBeUndefined();
     });
 
-    test("handles prompt errors", async () => {
-      (ctx.client.session.prompt as any).mockRejectedValue(new Error("Prompt failed"));
-      const result = await sendPrompt(ctx, "s1", "prompt", "agent");
-      expect(result.error).toContain("Failed to send prompt: Prompt failed");
+    test('handles prompt errors', async () => {
+      (ctx.client.session.prompt as any).mockRejectedValue(
+        new Error('Prompt failed'),
+      );
+      const result = await sendPrompt(ctx, 's1', 'prompt', 'agent');
+      expect(result.error).toContain('Failed to send prompt: Prompt failed');
     });
   });
 
-  describe("pollSession", () => {
-    test("completes when message count is stable", async () => {
-      let calls = 0;
-      (ctx.client.session.status as any).mockResolvedValue({ data: { "s1": { type: "idle" } } });
+  describe('pollSession', () => {
+    test('completes when message count is stable', async () => {
+      let _calls = 0;
+      (ctx.client.session.status as any).mockResolvedValue({
+        data: { s1: { type: 'idle' } },
+      });
       (ctx.client.session.messages as any).mockImplementation(async () => {
-        calls++;
+        _calls++;
         // First 2 calls return 1 message, next calls return 1 message (stable)
         return { data: new Array(1).fill({}) };
       });
 
-      const result = await pollSession(ctx, "s1", new AbortController().signal);
+      const result = await pollSession(ctx, 's1', new AbortController().signal);
       expect(result.error).toBeUndefined();
       expect(result.timeout).toBeUndefined();
     });
 
-    test("resets stability when status is not idle", async () => {
-        let statusCalls = 0;
-        (ctx.client.session.status as any).mockImplementation(async () => {
-            statusCalls++;
-            return { data: { "s1": { type: statusCalls === 1 ? "busy" : "idle" } } };
-        });
-        (ctx.client.session.messages as any).mockResolvedValue({ data: [{}, {}] });
-
-        // This will take a few more polls because of the busy status
-        const result = await pollSession(ctx, "s1", new AbortController().signal);
-        expect(result.error).toBeUndefined();
-    });
-
-    test("handles abort signal", async () => {
-        const controller = new AbortController();
-        controller.abort();
-        const result = await pollSession(ctx, "s1", controller.signal);
-        expect(result.aborted).toBe(true);
-    });
-
-    test("handles error getting status", async () => {
-        (ctx.client.session.status as any).mockResolvedValue({ error: "Status failed" });
-        const result = await pollSession(ctx, "s1", new AbortController().signal);
-        expect(result.error).toContain("Failed to get session status: Status failed");
-    });
-
-    test("handles error getting messages", async () => {
-        (ctx.client.session.status as any).mockResolvedValue({ data: { "s1": { type: "idle" } } });
-        (ctx.client.session.messages as any).mockResolvedValue({ error: "Messages failed" });
-        const result = await pollSession(ctx, "s1", new AbortController().signal);
-        expect(result.error).toContain("Failed to check messages: Messages failed");
-    });
-
-    test("times out", async () => {
-        // Mock Date.now to simulate timeout
-        const originalNow = Date.now;
-        let now = 1000;
-        Date.now = () => {
-            now += MAX_POLL_TIME_MS + 1000;
-            return now;
-        };
-        
-        try {
-            const result = await pollSession(ctx, "s1", new AbortController().signal);
-            expect(result.timeout).toBe(true);
-        } finally {
-            Date.now = originalNow;
-        }
+    test('resets stability when status is not idle', async () => {
+      let statusCalls = 0;
+      (ctx.client.session.status as any).mockImplementation(async () => {
+        statusCalls++;
+        return { data: { s1: { type: statusCalls === 1 ? 'busy' : 'idle' } } };
+      });
+      (ctx.client.session.messages as any).mockResolvedValue({
+        data: [{}, {}],
+      });
+
+      // This will take a few more polls because of the busy status
+      const result = await pollSession(ctx, 's1', new AbortController().signal);
+      expect(result.error).toBeUndefined();
+    });
+
+    test('handles abort signal', async () => {
+      const controller = new AbortController();
+      controller.abort();
+      const result = await pollSession(ctx, 's1', controller.signal);
+      expect(result.aborted).toBe(true);
+    });
+
+    test('handles error getting status', async () => {
+      (ctx.client.session.status as any).mockResolvedValue({
+        error: 'Status failed',
+      });
+      const result = await pollSession(ctx, 's1', new AbortController().signal);
+      expect(result.error).toContain(
+        'Failed to get session status: Status failed',
+      );
+    });
+
+    test('handles error getting messages', async () => {
+      (ctx.client.session.status as any).mockResolvedValue({
+        data: { s1: { type: 'idle' } },
+      });
+      (ctx.client.session.messages as any).mockResolvedValue({
+        error: 'Messages failed',
+      });
+      const result = await pollSession(ctx, 's1', new AbortController().signal);
+      expect(result.error).toContain(
+        'Failed to check messages: Messages failed',
+      );
+    });
+
+    test('times out', async () => {
+      // Mock Date.now to simulate timeout
+      const originalNow = Date.now;
+      let now = 1000;
+      Date.now = () => {
+        now += MAX_POLL_TIME_MS + 1000;
+        return now;
+      };
+
+      try {
+        const result = await pollSession(
+          ctx,
+          's1',
+          new AbortController().signal,
+        );
+        expect(result.timeout).toBe(true);
+      } finally {
+        Date.now = originalNow;
+      }
     });
   });
 
-  describe("extractResponseText", () => {
-    test("filters assistant messages and extracts content", () => {
+  describe('extractResponseText', () => {
+    test('filters assistant messages and extracts content', () => {
       const messages = [
-        { info: { role: "user" }, parts: [{ type: "text", text: "hi" }] },
-        { info: { role: "assistant" }, parts: [
-            { type: "reasoning", text: "thought" },
-            { type: "text", text: "hello" }
-        ]},
-        { info: { role: "assistant" }, parts: [
-            { type: "text", text: "world" },
-            { type: "text", text: "" }
-        ]}
+        { info: { role: 'user' }, parts: [{ type: 'text', text: 'hi' }] },
+        {
+          info: { role: 'assistant' },
+          parts: [
+            { type: 'reasoning', text: 'thought' },
+            { type: 'text', text: 'hello' },
+          ],
+        },
+        {
+          info: { role: 'assistant' },
+          parts: [
+            { type: 'text', text: 'world' },
+            { type: 'text', text: '' },
+          ],
+        },
       ];
       const result = extractResponseText(messages as any);
-      expect(result).toBe("thought\n\nhello\n\nworld");
+      expect(result).toBe('thought\n\nhello\n\nworld');
     });
 
-    test("returns empty string if no assistant messages", () => {
-      const result = extractResponseText([{ info: { role: "user" } }] as any);
-      expect(result).toBe("");
+    test('returns empty string if no assistant messages', () => {
+      const result = extractResponseText([{ info: { role: 'user' } }] as any);
+      expect(result).toBe('');
     });
   });
 });
-

+ 118 - 60
src/tools/background.ts

@@ -1,16 +1,20 @@
-import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin";
-import type { BackgroundTaskManager } from "../background";
-import { SUBAGENT_NAMES } from "../config";
 import {
-  POLL_INTERVAL_MS,
-  MAX_POLL_TIME_MS,
+  type PluginInput,
+  type ToolDefinition,
+  tool,
+} from '@opencode-ai/plugin';
+import type { BackgroundTaskManager } from '../background';
+import type { PluginConfig } from '../config';
+import {
   DEFAULT_TIMEOUT_MS,
+  MAX_POLL_TIME_MS,
+  POLL_INTERVAL_MS,
   STABLE_POLLS_THRESHOLD,
-} from "../config";
-import type { TmuxConfig } from "../config/schema";
-import type { PluginConfig } from "../config";
-import { applyAgentVariant, resolveAgentVariant } from "../utils";
-import { log } from "../utils/logger";
+  SUBAGENT_NAMES,
+} from '../config';
+import type { TmuxConfig } from '../config/schema';
+import { applyAgentVariant, resolveAgentVariant } from '../utils';
+import { log } from '../utils/logger';
 
 const z = tool.schema;
 
@@ -42,9 +46,9 @@ export function createBackgroundTools(
   ctx: PluginInput,
   manager: BackgroundTaskManager,
   tmuxConfig?: TmuxConfig,
-  pluginConfig?: PluginConfig
+  pluginConfig?: PluginConfig,
 ): Record<string, ToolDefinition> {
-  const agentNames = SUBAGENT_NAMES.join(", ");
+  const agentNames = SUBAGENT_NAMES.join(', ');
 
   // Tool for launching agent tasks (async or sync mode)
   const background_task = tool({
@@ -55,16 +59,28 @@ Agents: ${agentNames}.
 Async mode returns task_id immediately - use \`background_output\` to get results.
 Sync mode blocks until completion and returns the result directly.`,
     args: {
-      description: z.string().describe("Short description of the task (5-10 words)"),
-      prompt: z.string().describe("The task prompt for the agent"),
+      description: z
+        .string()
+        .describe('Short description of the task (5-10 words)'),
+      prompt: z.string().describe('The task prompt for the agent'),
       agent: z.string().describe(`Agent to use: ${agentNames}`),
-      sync: z.boolean().optional().describe("Wait for completion (default: false = async)"),
-      session_id: z.string().optional().describe("Continue existing session (sync mode only)"),
+      sync: z
+        .boolean()
+        .optional()
+        .describe('Wait for completion (default: false = async)'),
+      session_id: z
+        .string()
+        .optional()
+        .describe('Continue existing session (sync mode only)'),
     },
     async execute(args, toolContext) {
       // Validate tool context has required sessionID
-      if (!toolContext || typeof toolContext !== "object" || !("sessionID" in toolContext)) {
-        throw new Error("Invalid toolContext: missing sessionID");
+      if (
+        !toolContext ||
+        typeof toolContext !== 'object' ||
+        !('sessionID' in toolContext)
+      ) {
+        throw new Error('Invalid toolContext: missing sessionID');
       }
       const agent = String(args.agent);
       const prompt = String(args.prompt);
@@ -81,7 +97,7 @@ Sync mode blocks until completion and returns the result directly.`,
           ctx,
           tmuxConfig,
           pluginConfig,
-          args.session_id as string | undefined
+          args.session_id as string | undefined,
         );
       }
 
@@ -105,16 +121,23 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
 
   // Tool for retrieving output from background tasks
   const background_output = tool({
-    description: "Get output from background task.",
+    description: 'Get output from background task.',
     args: {
-      task_id: z.string().describe("Task ID from background_task"),
-      block: z.boolean().optional().describe("Wait for completion (default: false)"),
-      timeout: z.number().optional().describe("Timeout in ms (default: 120000)"),
+      task_id: z.string().describe('Task ID from background_task'),
+      block: z
+        .boolean()
+        .optional()
+        .describe('Wait for completion (default: false)'),
+      timeout: z
+        .number()
+        .optional()
+        .describe('Timeout in ms (default: 120000)'),
     },
-async execute(args) {
+    async execute(args) {
       const taskId = String(args.task_id);
       const block = args.block === true;
-      const timeout = typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT_MS;
+      const timeout =
+        typeof args.timeout === 'number' ? args.timeout : DEFAULT_TIMEOUT_MS;
 
       // Retrieve task result (optionally blocking until completion)
       const task = await manager.getResult(taskId, block, timeout);
@@ -125,7 +148,7 @@ async execute(args) {
       // Calculate task duration
       const duration = task.completedAt
         ? `${Math.floor((task.completedAt.getTime() - task.startedAt.getTime()) / 1000)}s`
-        : "running";
+        : 'running';
 
       let output = `Task: ${task.id}
  Description: ${task.description}
@@ -137,12 +160,12 @@ async execute(args) {
  `;
 
       // Include task result or error based on status
-      if (task.status === "completed" && task.result != null) {
+      if (task.status === 'completed' && task.result != null) {
         output += task.result;
-      } else if (task.status === "failed") {
+      } else if (task.status === 'failed') {
         output += `Error: ${task.error}`;
       } else {
-        output += "(Task still running)";
+        output += '(Task still running)';
       }
 
       return output;
@@ -151,10 +174,11 @@ async execute(args) {
 
   // Tool for canceling running background tasks
   const background_cancel = tool({
-    description: "Cancel running background task(s). Use all=true to cancel all.",
+    description:
+      'Cancel running background task(s). Use all=true to cancel all.',
     args: {
-      task_id: z.string().optional().describe("Specific task to cancel"),
-      all: z.boolean().optional().describe("Cancel all running tasks"),
+      task_id: z.string().optional().describe('Specific task to cancel'),
+      all: z.boolean().optional().describe('Cancel all running tasks'),
     },
     async execute(args) {
       // Cancel all running tasks if requested
@@ -164,12 +188,14 @@ async execute(args) {
       }
 
       // Cancel specific task if task_id provided
-      if (typeof args.task_id === "string") {
+      if (typeof args.task_id === 'string') {
         const count = manager.cancel(args.task_id);
-        return count > 0 ? `Cancelled task ${args.task_id}.` : `Task ${args.task_id} not found or not running.`;
+        return count > 0
+          ? `Cancelled task ${args.task_id}.`
+          : `Task ${args.task_id} not found or not running.`;
       }
 
-      return "Specify task_id or use all=true.";
+      return 'Specify task_id or use all=true.';
     },
   });
 
@@ -197,7 +223,7 @@ async function executeSync(
   ctx: PluginInput,
   tmuxConfig?: TmuxConfig,
   pluginConfig?: PluginConfig,
-  existingSessionId?: string
+  existingSessionId?: string,
 ): Promise<string> {
   // Resolve or create session for the task
   const { sessionID, error: sessionError } = await resolveSessionId(
@@ -206,7 +232,7 @@ async function executeSync(
     description,
     agent,
     tmuxConfig,
-    existingSessionId
+    existingSessionId,
   );
 
   if (sessionError) {
@@ -214,10 +240,18 @@ async function executeSync(
   }
 
   // Disable recursive delegation tools to prevent infinite loops
-  log(`[background-sync] launching sync task for agent="${agent}"`, { description });
+  log(`[background-sync] launching sync task for agent="${agent}"`, {
+    description,
+  });
 
   // Send prompt to the session
-  const { error: promptError } = await sendPrompt(ctx, sessionID, prompt, agent, pluginConfig);
+  const { error: promptError } = await sendPrompt(
+    ctx,
+    sessionID,
+    prompt,
+    agent,
+    pluginConfig,
+  );
   if (promptError) {
     return withTaskMetadata(`Error: ${promptError}`, sessionID);
   }
@@ -225,18 +259,23 @@ async function executeSync(
   // Poll session until completion, abort, or timeout
   const pollResult = await pollSession(ctx, sessionID, toolContext.abort);
   if (pollResult.aborted) {
-    return withTaskMetadata("Task aborted.", sessionID);
+    return withTaskMetadata('Task aborted.', sessionID);
   }
   if (pollResult.timeout) {
     const minutes = Math.floor(MAX_POLL_TIME_MS / 60000);
-    return withTaskMetadata(`Error: Agent timed out after ${minutes} minutes.`, sessionID);
+    return withTaskMetadata(
+      `Error: Agent timed out after ${minutes} minutes.`,
+      sessionID,
+    );
   }
   if (pollResult.error) {
     return withTaskMetadata(`Error: ${pollResult.error}`, sessionID);
   }
 
   // Retrieve and extract the agent's response
-  const messagesResult = await ctx.client.session.messages({ path: { id: sessionID } });
+  const messagesResult = await ctx.client.session.messages({
+    path: { id: sessionID },
+  });
   if (messagesResult.error) {
     return `Error: Failed to get messages: ${messagesResult.error}`;
   }
@@ -245,7 +284,7 @@ async function executeSync(
   const responseText = extractResponseText(messages);
 
   if (!responseText) {
-    return withTaskMetadata("Error: No response from agent.", sessionID);
+    return withTaskMetadata('Error: No response from agent.', sessionID);
   }
 
   // Pane closing is handled by TmuxSessionManager via polling
@@ -261,13 +300,18 @@ export async function resolveSessionId(
   description: string,
   agent: string,
   tmuxConfig?: TmuxConfig,
-  existingSessionId?: string
+  existingSessionId?: string,
 ): Promise<{ sessionID: string; error?: string }> {
   // If existing session ID provided, validate and return it
   if (existingSessionId) {
-    const sessionResult = await ctx.client.session.get({ path: { id: existingSessionId } });
+    const sessionResult = await ctx.client.session.get({
+      path: { id: existingSessionId },
+    });
     if (sessionResult.error) {
-      return { sessionID: "", error: `Failed to get session: ${sessionResult.error}` };
+      return {
+        sessionID: '',
+        error: `Failed to get session: ${sessionResult.error}`,
+      };
     }
     return { sessionID: existingSessionId };
   }
@@ -283,10 +327,12 @@ export async function createSession(
   toolContext: ToolContext,
   description: string,
   agent: string,
-  tmuxConfig?: TmuxConfig
+  tmuxConfig?: TmuxConfig,
 ): Promise<{ sessionID: string; error?: string }> {
   // Get parent session to inherit directory context
-  const parentSession = await ctx.client.session.get({ path: { id: toolContext.sessionID } }).catch(() => null);
+  const parentSession = await ctx.client.session
+    .get({ path: { id: toolContext.sessionID } })
+    .catch(() => null);
   const parentDirectory = parentSession?.data?.directory ?? ctx.directory;
 
   // Create new session with parent relationship
@@ -299,7 +345,10 @@ export async function createSession(
   });
 
   if (createResult.error) {
-    return { sessionID: "", error: `Failed to create session: ${createResult.error}` };
+    return {
+      sessionID: '',
+      error: `Failed to create session: ${createResult.error}`,
+    };
   }
 
   // Give TmuxSessionManager time to spawn the pane via event hook
@@ -319,7 +368,7 @@ export async function sendPrompt(
   sessionID: string,
   prompt: string,
   agent: string,
-  pluginConfig?: PluginConfig
+  pluginConfig?: PluginConfig,
 ): Promise<{ error?: string }> {
   // Resolve agent variant configuration
   const resolvedVariant = resolveAgentVariant(pluginConfig, agent);
@@ -327,7 +376,7 @@ export async function sendPrompt(
   type PromptBody = {
     agent: string;
     tools: { background_task: boolean; task: boolean };
-    parts: Array<{ type: "text"; text: string }>;
+    parts: Array<{ type: 'text'; text: string }>;
     variant?: string;
   };
 
@@ -335,7 +384,7 @@ export async function sendPrompt(
   const baseBody: PromptBody = {
     agent,
     tools: { background_task: false, task: false },
-    parts: [{ type: "text" as const, text: prompt }],
+    parts: [{ type: 'text' as const, text: prompt }],
   };
   const promptBody = applyAgentVariant(resolvedVariant, baseBody);
 
@@ -347,7 +396,9 @@ export async function sendPrompt(
     });
     return {};
   } catch (error) {
-    return { error: `Failed to send prompt: ${error instanceof Error ? error.message : String(error)}` };
+    return {
+      error: `Failed to send prompt: ${error instanceof Error ? error.message : String(error)}`,
+    };
   }
 }
 
@@ -357,7 +408,7 @@ export async function sendPrompt(
 export async function pollSession(
   ctx: PluginInput,
   sessionID: string,
-  abortSignal: AbortSignal
+  abortSignal: AbortSignal,
 ): Promise<{ error?: string; timeout?: boolean; aborted?: boolean }> {
   const pollStart = Date.now();
   let lastMsgCount = 0;
@@ -377,17 +428,22 @@ export async function pollSession(
     if (statusResult.error) {
       return { error: `Failed to get session status: ${statusResult.error}` };
     }
-    const allStatuses = (statusResult.data ?? {}) as Record<string, SessionStatus>;
+    const allStatuses = (statusResult.data ?? {}) as Record<
+      string,
+      SessionStatus
+    >;
     const sessionStatus = allStatuses[sessionID];
 
-    if (sessionStatus && sessionStatus.type !== "idle") {
+    if (sessionStatus && sessionStatus.type !== 'idle') {
       stablePolls = 0;
       lastMsgCount = 0;
       continue;
     }
 
     // Check message count - if stable for threshold, task is complete
-    const messagesCheck = await ctx.client.session.messages({ path: { id: sessionID } });
+    const messagesCheck = await ctx.client.session.messages({
+      path: { id: sessionID },
+    });
     if (messagesCheck.error) {
       return { error: `Failed to check messages: ${messagesCheck.error}` };
     }
@@ -411,20 +467,22 @@ export async function pollSession(
  */
 export function extractResponseText(messages: SessionMessage[]): string {
   // Filter for assistant messages only
-  const assistantMessages = messages.filter((m) => m.info?.role === "assistant");
+  const assistantMessages = messages.filter(
+    (m) => m.info?.role === 'assistant',
+  );
   const extractedContent: string[] = [];
 
   // Extract text and reasoning content from message parts
   for (const message of assistantMessages) {
     for (const part of message.parts ?? []) {
-      if ((part.type === "text" || part.type === "reasoning") && part.text) {
+      if ((part.type === 'text' || part.type === 'reasoning') && part.text) {
         extractedContent.push(part.text);
       }
     }
   }
 
   // Join non-empty content with double newlines
-  return extractedContent.filter((t) => t.length > 0).join("\n\n");
+  return extractedContent.filter((t) => t.length > 0).join('\n\n');
 }
 
 /**

+ 111 - 93
src/tools/grep/cli.ts

@@ -1,17 +1,17 @@
-import { spawn } from "bun"
+import { spawn } from 'bun';
 import {
-  resolveGrepCli,
-  type GrepBackend,
+  DEFAULT_MAX_COLUMNS,
+  DEFAULT_MAX_COUNT,
   DEFAULT_MAX_DEPTH,
   DEFAULT_MAX_FILESIZE,
-  DEFAULT_MAX_COUNT,
-  DEFAULT_MAX_COLUMNS,
-  DEFAULT_TIMEOUT_MS,
   DEFAULT_MAX_OUTPUT_BYTES,
-  RG_SAFETY_FLAGS,
+  DEFAULT_TIMEOUT_MS,
   GREP_SAFETY_FLAGS,
-} from "./constants"
-import type { GrepOptions, GrepMatch, GrepResult, CountResult } from "./types"
+  type GrepBackend,
+  RG_SAFETY_FLAGS,
+  resolveGrepCli,
+} from './constants';
+import type { CountResult, GrepMatch, GrepOptions, GrepResult } from './types';
 
 function buildRgArgs(options: GrepOptions): string[] {
   const args: string[] = [
@@ -20,148 +20,156 @@ function buildRgArgs(options: GrepOptions): string[] {
     `--max-filesize=${options.maxFilesize ?? DEFAULT_MAX_FILESIZE}`,
     `--max-count=${Math.min(options.maxCount ?? DEFAULT_MAX_COUNT, DEFAULT_MAX_COUNT)}`,
     `--max-columns=${Math.min(options.maxColumns ?? DEFAULT_MAX_COLUMNS, DEFAULT_MAX_COLUMNS)}`,
-  ]
+  ];
 
   if (options.context !== undefined && options.context > 0) {
-    args.push(`-C${Math.min(options.context, 10)}`)
+    args.push(`-C${Math.min(options.context, 10)}`);
   }
 
-  if (options.caseSensitive) args.push("--case-sensitive")
-  if (options.wholeWord) args.push("-w")
-  if (options.fixedStrings) args.push("-F")
-  if (options.multiline) args.push("-U")
-  if (options.hidden) args.push("--hidden")
-  if (options.noIgnore) args.push("--no-ignore")
+  if (options.caseSensitive) args.push('--case-sensitive');
+  if (options.wholeWord) args.push('-w');
+  if (options.fixedStrings) args.push('-F');
+  if (options.multiline) args.push('-U');
+  if (options.hidden) args.push('--hidden');
+  if (options.noIgnore) args.push('--no-ignore');
 
   if (options.fileType?.length) {
     for (const type of options.fileType) {
-      args.push(`--type=${type}`)
+      args.push(`--type=${type}`);
     }
   }
 
   if (options.globs) {
     for (const glob of options.globs) {
-      args.push(`--glob=${glob}`)
+      args.push(`--glob=${glob}`);
     }
   }
 
   if (options.excludeGlobs) {
     for (const glob of options.excludeGlobs) {
-      args.push(`--glob=!${glob}`)
+      args.push(`--glob=!${glob}`);
     }
   }
 
-  return args
+  return args;
 }
 
 function buildGrepArgs(options: GrepOptions): string[] {
-  const args: string[] = [...GREP_SAFETY_FLAGS, "-r"]
+  const args: string[] = [...GREP_SAFETY_FLAGS, '-r'];
 
   if (options.context !== undefined && options.context > 0) {
-    args.push(`-C${Math.min(options.context, 10)}`)
+    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.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}`)
+      args.push(`--include=${glob}`);
     }
   }
 
   if (options.excludeGlobs?.length) {
     for (const glob of options.excludeGlobs) {
-      args.push(`--exclude=${glob}`)
+      args.push(`--exclude=${glob}`);
     }
   }
 
-  args.push("--exclude-dir=.git", "--exclude-dir=node_modules")
+  args.push('--exclude-dir=.git', '--exclude-dir=node_modules');
 
-  return args
+  return args;
 }
 
 function buildArgs(options: GrepOptions, backend: GrepBackend): string[] {
-  return backend === "rg" ? buildRgArgs(options) : buildGrepArgs(options)
+  return backend === 'rg' ? buildRgArgs(options) : buildGrepArgs(options);
 }
 
 function parseOutput(output: string): GrepMatch[] {
-  if (!output.trim()) return []
+  if (!output.trim()) return [];
 
-  const matches: GrepMatch[] = []
-  const lines = output.split("\n")
+  const matches: GrepMatch[] = [];
+  const lines = output.split('\n');
 
   for (const line of lines) {
-    if (!line.trim()) continue
+    if (!line.trim()) continue;
 
-    const match = line.match(/^(.+?):(\d+):(.*)$/)
+    const match = line.match(/^(.+?):(\d+):(.*)$/);
     if (match) {
       matches.push({
         file: match[1],
         line: parseInt(match[2], 10),
         text: match[3],
-      })
+      });
     }
   }
 
-  return matches
+  return matches;
 }
 
 function parseCountOutput(output: string): CountResult[] {
-  if (!output.trim()) return []
+  if (!output.trim()) return [];
 
-  const results: CountResult[] = []
-  const lines = output.split("\n")
+  const results: CountResult[] = [];
+  const lines = output.split('\n');
 
   for (const line of lines) {
-    if (!line.trim()) continue
+    if (!line.trim()) continue;
 
-    const match = line.match(/^(.+?):(\d+)$/)
+    const match = line.match(/^(.+?):(\d+)$/);
     if (match) {
       results.push({
         file: match[1],
         count: parseInt(match[2], 10),
-      })
+      });
     }
   }
 
-  return results
+  return results;
 }
 
 export async function runRg(options: GrepOptions): Promise<GrepResult> {
-  const cli = resolveGrepCli()
-  const args = buildArgs(options, cli.backend)
-  const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
-
-  if (cli.backend === "rg") {
-    args.push("--", options.pattern)
+  const cli = resolveGrepCli();
+  const args = buildArgs(options, cli.backend);
+  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('-e', options.pattern);
   }
 
-  const paths = options.paths?.length ? options.paths : ["."]
-  args.push(...paths)
+  const paths = options.paths?.length ? options.paths : ['.'];
+  args.push(...paths);
   const proc = spawn([cli.path, ...args], {
-    stdout: "pipe",
-    stderr: "pipe",
-  })
+    stdout: 'pipe',
+    stderr: 'pipe',
+  });
 
   const timeoutPromise = new Promise<never>((_, reject) => {
     const id = setTimeout(() => {
-      proc.kill()
-      reject(new Error(`Search timeout after ${timeout}ms`))
-    }, timeout)
-    proc.exited.then(() => clearTimeout(id))
-  })
+      proc.kill();
+      reject(new Error(`Search timeout after ${timeout}ms`));
+    }, timeout);
+    proc.exited.then(() => clearTimeout(id));
+  });
 
   try {
-    const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
-    const stderr = await new Response(proc.stderr).text()
-    const exitCode = await proc.exited
-
-    const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
-    const outputToProcess = truncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
+    const stdout = await Promise.race([
+      new Response(proc.stdout).text(),
+      timeoutPromise,
+    ]);
+    const stderr = await new Response(proc.stderr).text();
+    const exitCode = await proc.exited;
+
+    const truncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES;
+    const outputToProcess = truncated
+      ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES)
+      : stdout;
 
     if (exitCode > 1 && stderr.trim()) {
       return {
@@ -170,18 +178,18 @@ export async function runRg(options: GrepOptions): Promise<GrepResult> {
         filesSearched: 0,
         truncated: false,
         error: stderr.trim(),
-      }
+      };
     }
 
-    const matches = parseOutput(outputToProcess)
-    const filesSearched = new Set(matches.map((m) => m.file)).size
+    const matches = parseOutput(outputToProcess);
+    const filesSearched = new Set(matches.map((m) => m.file)).size;
 
     return {
       matches,
       totalMatches: matches.length,
       filesSearched,
       truncated,
-    }
+    };
   } catch (e) {
     return {
       matches: [],
@@ -189,41 +197,51 @@ export async function runRg(options: GrepOptions): Promise<GrepResult> {
       filesSearched: 0,
       truncated: false,
       error: e instanceof Error ? e.message : String(e),
-    }
+    };
   }
 }
 
-export async function runRgCount(options: Omit<GrepOptions, "context">): Promise<CountResult[]> {
-  const cli = resolveGrepCli()
-  const args = buildArgs({ ...options, context: 0 }, cli.backend)
+export async function runRgCount(
+  options: Omit<GrepOptions, 'context'>,
+): Promise<CountResult[]> {
+  const cli = resolveGrepCli();
+  const args = buildArgs({ ...options, context: 0 }, cli.backend);
 
-  if (cli.backend === "rg") {
-    args.push("--count", "--", options.pattern)
+  if (cli.backend === 'rg') {
+    args.push('--count', '--', options.pattern);
   } else {
-    args.push("-c", "-e", options.pattern)
+    args.push('-c', '-e', options.pattern);
   }
 
-  const paths = options.paths?.length ? options.paths : ["."]
-  args.push(...paths)
+  const paths = options.paths?.length ? options.paths : ['.'];
+  args.push(...paths);
 
-  const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
+  const timeout = Math.min(
+    options.timeout ?? DEFAULT_TIMEOUT_MS,
+    DEFAULT_TIMEOUT_MS,
+  );
   const proc = spawn([cli.path, ...args], {
-    stdout: "pipe",
-    stderr: "pipe",
-  })
+    stdout: 'pipe',
+    stderr: 'pipe',
+  });
 
   const timeoutPromise = new Promise<never>((_, reject) => {
     const id = setTimeout(() => {
-      proc.kill()
-      reject(new Error(`Search timeout after ${timeout}ms`))
-    }, timeout)
-    proc.exited.then(() => clearTimeout(id))
-  })
+      proc.kill();
+      reject(new Error(`Search timeout after ${timeout}ms`));
+    }, timeout);
+    proc.exited.then(() => clearTimeout(id));
+  });
 
   try {
-    const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
-    return parseCountOutput(stdout)
+    const stdout = await Promise.race([
+      new Response(proc.stdout).text(),
+      timeoutPromise,
+    ]);
+    return parseCountOutput(stdout);
   } catch (e) {
-    throw new Error(`Count search failed: ${e instanceof Error ? e.message : String(e)}`)
+    throw new Error(
+      `Count search failed: ${e instanceof Error ? e.message : String(e)}`,
+    );
   }
 }

+ 77 - 67
src/tools/grep/constants.ts

@@ -1,133 +1,143 @@
-import { existsSync } from "node:fs"
-import { join, dirname } from "node:path"
-import { spawnSync } from "node:child_process"
-import { getInstalledRipgrepPath, downloadAndInstallRipgrep } from "./downloader"
+import { spawnSync } from 'node:child_process';
+import { existsSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import {
+  downloadAndInstallRipgrep,
+  getInstalledRipgrepPath,
+} from './downloader';
 
-export type GrepBackend = "rg" | "grep"
+export type GrepBackend = 'rg' | 'grep';
 
 interface ResolvedCli {
-  path: string
-  backend: GrepBackend
+  path: string;
+  backend: GrepBackend;
 }
 
-let cachedCli: ResolvedCli | null = null
-let autoInstallAttempted = false
+let cachedCli: ResolvedCli | null = null;
+let autoInstallAttempted = false;
 
 function findExecutable(name: string): string | null {
-  const isWindows = process.platform === "win32"
-  const cmd = isWindows ? "where" : "which"
+  const isWindows = process.platform === 'win32';
+  const cmd = isWindows ? 'where' : 'which';
 
   try {
-    const result = spawnSync(cmd, [name], { encoding: "utf-8", timeout: 5000 })
+    const result = spawnSync(cmd, [name], { encoding: 'utf-8', timeout: 5000 });
     if (result.status === 0 && result.stdout.trim()) {
-      return result.stdout.trim().split("\n")[0]
+      return result.stdout.trim().split('\n')[0];
     }
   } catch {
     // Command execution failed
   }
-  return null
+  return null;
 }
 
 function getDataDir(): string {
-  if (process.platform === "win32") {
-    return process.env.LOCALAPPDATA || process.env.APPDATA || join(process.env.USERPROFILE || ".", "AppData", "Local")
+  if (process.platform === 'win32') {
+    return (
+      process.env.LOCALAPPDATA ||
+      process.env.APPDATA ||
+      join(process.env.USERPROFILE || '.', 'AppData', 'Local')
+    );
   }
-  return process.env.XDG_DATA_HOME || join(process.env.HOME || ".", ".local", "share")
+  return (
+    process.env.XDG_DATA_HOME ||
+    join(process.env.HOME || '.', '.local', 'share')
+  );
 }
 
 function getOpenCodeBundledRg(): string | null {
-  const execPath = process.execPath
-  const execDir = dirname(execPath)
+  const execPath = process.execPath;
+  const execDir = dirname(execPath);
 
-  const isWindows = process.platform === "win32"
-  const rgName = isWindows ? "rg.exe" : "rg"
+  const isWindows = process.platform === 'win32';
+  const rgName = isWindows ? 'rg.exe' : 'rg';
 
   const candidates = [
     // OpenCode XDG data path (highest priority - where OpenCode installs rg)
-    join(getDataDir(), "opencode", "bin", rgName),
+    join(getDataDir(), 'opencode', 'bin', rgName),
     // Legacy paths relative to execPath
     join(execDir, rgName),
-    join(execDir, "bin", rgName),
-    join(execDir, "..", "bin", rgName),
-    join(execDir, "..", "libexec", rgName),
-  ]
+    join(execDir, 'bin', rgName),
+    join(execDir, '..', 'bin', rgName),
+    join(execDir, '..', 'libexec', rgName),
+  ];
 
   for (const candidate of candidates) {
     if (existsSync(candidate)) {
-      return candidate
+      return candidate;
     }
   }
 
-  return null
+  return null;
 }
 
 export function resolveGrepCli(): ResolvedCli {
-  if (cachedCli) return cachedCli
+  if (cachedCli) return cachedCli;
 
-  const bundledRg = getOpenCodeBundledRg()
+  const bundledRg = getOpenCodeBundledRg();
   if (bundledRg) {
-    cachedCli = { path: bundledRg, backend: "rg" }
-    return cachedCli
+    cachedCli = { path: bundledRg, backend: 'rg' };
+    return cachedCli;
   }
 
-  const systemRg = findExecutable("rg")
+  const systemRg = findExecutable('rg');
   if (systemRg) {
-    cachedCli = { path: systemRg, backend: "rg" }
-    return cachedCli
+    cachedCli = { path: systemRg, backend: 'rg' };
+    return cachedCli;
   }
 
-  const installedRg = getInstalledRipgrepPath()
+  const installedRg = getInstalledRipgrepPath();
   if (installedRg) {
-    cachedCli = { path: installedRg, backend: "rg" }
-    return cachedCli
+    cachedCli = { path: installedRg, backend: 'rg' };
+    return cachedCli;
   }
 
-  const grep = findExecutable("grep")
+  const grep = findExecutable('grep');
   if (grep) {
-    cachedCli = { path: grep, backend: "grep" }
-    return cachedCli
+    cachedCli = { path: grep, backend: 'grep' };
+    return cachedCli;
   }
 
-  cachedCli = { path: "rg", backend: "rg" }
-  return cachedCli
+  cachedCli = { path: 'rg', backend: 'rg' };
+  return cachedCli;
 }
 
 export async function resolveGrepCliWithAutoInstall(): Promise<ResolvedCli> {
-  const current = resolveGrepCli()
+  const current = resolveGrepCli();
 
-  if (current.backend === "rg") {
-    return current
+  if (current.backend === 'rg') {
+    return current;
   }
 
   if (autoInstallAttempted) {
-    return current
+    return current;
   }
 
-  autoInstallAttempted = true
+  autoInstallAttempted = true;
 
   try {
-    const rgPath = await downloadAndInstallRipgrep()
-    cachedCli = { path: rgPath, backend: "rg" }
-    return cachedCli
+    const rgPath = await downloadAndInstallRipgrep();
+    cachedCli = { path: rgPath, backend: 'rg' };
+    return cachedCli;
   } catch {
-    return current
+    return current;
   }
 }
 
-export const DEFAULT_MAX_DEPTH = 20
-export const DEFAULT_MAX_FILESIZE = "10M"
-export const DEFAULT_MAX_COUNT = 500
-export const DEFAULT_MAX_COLUMNS = 1000
-export const DEFAULT_CONTEXT = 2
-export const DEFAULT_TIMEOUT_MS = 300_000
-export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
+export const DEFAULT_MAX_DEPTH = 20;
+export const DEFAULT_MAX_FILESIZE = '10M';
+export const DEFAULT_MAX_COUNT = 500;
+export const DEFAULT_MAX_COLUMNS = 1000;
+export const DEFAULT_CONTEXT = 2;
+export const DEFAULT_TIMEOUT_MS = 300_000;
+export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024;
 
 export const RG_SAFETY_FLAGS = [
-  "--no-follow",
-  "--color=never",
-  "--no-heading",
-  "--line-number",
-  "--with-filename",
-] as const
-
-export const GREP_SAFETY_FLAGS = ["-n", "-H", "--color=never"] as const
+  '--no-follow',
+  '--color=never',
+  '--no-heading',
+  '--line-number',
+  '--with-filename',
+] as const;
+
+export const GREP_SAFETY_FLAGS = ['-n', '-H', '--color=never'] as const;

+ 89 - 70
src/tools/grep/downloader.ts

@@ -1,139 +1,158 @@
-import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync } from "node:fs"
-import { join } from "node:path"
-import { spawn } from "bun"
-import { extractZip } from "../../utils"
-
-export function findFileRecursive(dir: string, filename: string): string | null {
+import {
+  chmodSync,
+  existsSync,
+  mkdirSync,
+  readdirSync,
+  unlinkSync,
+} from 'node:fs';
+import { join } from 'node:path';
+import { spawn } from 'bun';
+import { extractZip } from '../../utils';
+
+export function findFileRecursive(
+  dir: string,
+  filename: string,
+): string | null {
   try {
-    const entries = readdirSync(dir, { withFileTypes: true, recursive: true })
+    const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
     for (const entry of entries) {
       if (entry.isFile() && entry.name === filename) {
-        return join(entry.parentPath ?? dir, entry.name)
+        return join(entry.parentPath ?? dir, entry.name);
       }
     }
   } catch {
-    return null
+    return null;
   }
-  return null
+  return null;
 }
 
-const RG_VERSION = "14.1.1"
+const RG_VERSION = '14.1.1';
 
 // Platform key format: ${process.platform}-${process.arch} (consistent with ast-grep)
-const PLATFORM_CONFIG: Record<string, { platform: string; extension: "tar.gz" | "zip" } | undefined> =
-{
-  "darwin-arm64": { platform: "aarch64-apple-darwin", extension: "tar.gz" },
-  "darwin-x64": { platform: "x86_64-apple-darwin", extension: "tar.gz" },
-  "linux-arm64": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" },
-  "linux-x64": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" },
-  "win32-x64": { platform: "x86_64-pc-windows-msvc", extension: "zip" },
-}
+const PLATFORM_CONFIG: Record<
+  string,
+  { platform: string; extension: 'tar.gz' | 'zip' } | undefined
+> = {
+  'darwin-arm64': { platform: 'aarch64-apple-darwin', extension: 'tar.gz' },
+  'darwin-x64': { platform: 'x86_64-apple-darwin', extension: 'tar.gz' },
+  'linux-arm64': { platform: 'aarch64-unknown-linux-gnu', extension: 'tar.gz' },
+  'linux-x64': { platform: 'x86_64-unknown-linux-musl', extension: 'tar.gz' },
+  'win32-x64': { platform: 'x86_64-pc-windows-msvc', extension: 'zip' },
+};
 
 function getPlatformKey(): string {
-  return `${process.platform}-${process.arch}`
+  return `${process.platform}-${process.arch}`;
 }
 
 function getInstallDir(): string {
-  const homeDir = process.env.HOME || process.env.USERPROFILE || "."
-  return join(homeDir, ".cache", "oh-my-opencode-slim", "bin")
+  const homeDir = process.env.HOME || process.env.USERPROFILE || '.';
+  return join(homeDir, '.cache', 'oh-my-opencode-slim', 'bin');
 }
 
 function getRgPath(): string {
-  const isWindows = process.platform === "win32"
-  return join(getInstallDir(), isWindows ? "rg.exe" : "rg")
+  const isWindows = process.platform === 'win32';
+  return join(getInstallDir(), isWindows ? 'rg.exe' : 'rg');
 }
 
 async function downloadFile(url: string, destPath: string): Promise<void> {
-  const response = await fetch(url)
+  const response = await fetch(url);
   if (!response.ok) {
-    throw new Error(`Failed to download: ${response.status} ${response.statusText}`)
+    throw new Error(
+      `Failed to download: ${response.status} ${response.statusText}`,
+    );
   }
 
-  const buffer = await response.arrayBuffer()
-  await Bun.write(destPath, buffer)
+  const buffer = await response.arrayBuffer();
+  await Bun.write(destPath, buffer);
 }
 
-async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
-  const args = ["tar", "-xzf", archivePath, "--strip-components=1"]
+async function extractTarGz(
+  archivePath: string,
+  destDir: string,
+): Promise<void> {
+  const args = ['tar', '-xzf', archivePath, '--strip-components=1'];
 
-  if (process.platform === "darwin") {
-    args.push("--include=*/rg")
-  } else if (process.platform === "linux") {
-    args.push("--wildcards", "*/rg")
+  if (process.platform === 'darwin') {
+    args.push('--include=*/rg');
+  } else if (process.platform === 'linux') {
+    args.push('--wildcards', '*/rg');
   }
 
   const proc = spawn(args, {
     cwd: destDir,
-    stdout: "pipe",
-    stderr: "pipe",
-  })
+    stdout: 'pipe',
+    stderr: 'pipe',
+  });
 
-  const exitCode = await proc.exited
+  const exitCode = await proc.exited;
   if (exitCode !== 0) {
-    const stderr = await new Response(proc.stderr).text()
-    throw new Error(`Failed to extract tar.gz: ${stderr}`)
+    const stderr = await new Response(proc.stderr).text();
+    throw new Error(`Failed to extract tar.gz: ${stderr}`);
   }
 }
 
-async function extractZipArchive(archivePath: string, destDir: string): Promise<void> {
-  await extractZip(archivePath, destDir)
+async function extractZipArchive(
+  archivePath: string,
+  destDir: string,
+): Promise<void> {
+  await extractZip(archivePath, destDir);
 
-  const binaryName = process.platform === "win32" ? "rg.exe" : "rg"
-  const foundPath = findFileRecursive(destDir, binaryName)
+  const binaryName = process.platform === 'win32' ? 'rg.exe' : 'rg';
+  const foundPath = findFileRecursive(destDir, binaryName);
   if (foundPath) {
-    const destPath = join(destDir, binaryName)
+    const destPath = join(destDir, binaryName);
     if (foundPath !== destPath) {
-      const { renameSync } = await import("node:fs")
-      renameSync(foundPath, destPath)
+      const { renameSync } = await import('node:fs');
+      renameSync(foundPath, destPath);
     }
   }
 }
 
 export async function downloadAndInstallRipgrep(): Promise<string> {
-  const platformKey = getPlatformKey()
-  const config = PLATFORM_CONFIG[platformKey]
+  const platformKey = getPlatformKey();
+  const config = PLATFORM_CONFIG[platformKey];
 
   if (!config) {
-    throw new Error(`Unsupported platform: ${platformKey}`)
+    throw new Error(`Unsupported platform: ${platformKey}`);
   }
 
-  const installDir = getInstallDir()
-  const rgPath = getRgPath()
+  const installDir = getInstallDir();
+  const rgPath = getRgPath();
 
   if (existsSync(rgPath)) {
-    return rgPath
+    return rgPath;
   }
 
-  mkdirSync(installDir, { recursive: true })
+  mkdirSync(installDir, { recursive: true });
 
-  const filename = `ripgrep-${RG_VERSION}-${config.platform}.${config.extension}`
-  const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}`
-  const archivePath = join(installDir, filename)
+  const filename = `ripgrep-${RG_VERSION}-${config.platform}.${config.extension}`;
+  const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}`;
+  const archivePath = join(installDir, filename);
 
   try {
-    console.log(`[oh-my-opencode-slim] Downloading ripgrep...`)
-    await downloadFile(url, archivePath)
+    console.log(`[oh-my-opencode-slim] Downloading ripgrep...`);
+    await downloadFile(url, archivePath);
 
-    if (config.extension === "tar.gz") {
-      await extractTarGz(archivePath, installDir)
+    if (config.extension === 'tar.gz') {
+      await extractTarGz(archivePath, installDir);
     } else {
-      await extractZipArchive(archivePath, installDir)
+      await extractZipArchive(archivePath, installDir);
     }
 
-    if (process.platform !== "win32") {
-      chmodSync(rgPath, 0o755)
+    if (process.platform !== 'win32') {
+      chmodSync(rgPath, 0o755);
     }
 
     if (!existsSync(rgPath)) {
-      throw new Error("ripgrep binary not found after extraction")
+      throw new Error('ripgrep binary not found after extraction');
     }
 
-    console.log(`[oh-my-opencode-slim] ripgrep ready.`)
-    return rgPath
+    console.log(`[oh-my-opencode-slim] ripgrep ready.`);
+    return rgPath;
   } finally {
     if (existsSync(archivePath)) {
       try {
-        unlinkSync(archivePath)
+        unlinkSync(archivePath);
       } catch {
         // Cleanup failures are non-critical
       }
@@ -142,6 +161,6 @@ export async function downloadAndInstallRipgrep(): Promise<string> {
 }
 
 export function getInstalledRipgrepPath(): string | null {
-  const rgPath = getRgPath()
-  return existsSync(rgPath) ? rgPath : null
+  const rgPath = getRgPath();
+  return existsSync(rgPath) ? rgPath : null;
 }

+ 8 - 5
src/tools/grep/index.ts

@@ -1,5 +1,8 @@
-export { grep } from "./tools"
-export { runRg, runRgCount } from "./cli"
-export { resolveGrepCli, resolveGrepCliWithAutoInstall } from "./constants"
-export { downloadAndInstallRipgrep, getInstalledRipgrepPath } from "./downloader"
-export type { GrepResult, GrepMatch, GrepOptions, CountResult } from "./types"
+export { runRg, runRgCount } from './cli';
+export { resolveGrepCli, resolveGrepCliWithAutoInstall } from './constants';
+export {
+  downloadAndInstallRipgrep,
+  getInstalledRipgrepPath,
+} from './downloader';
+export { grep } from './tools';
+export type { CountResult, GrepMatch, GrepOptions, GrepResult } from './types';

+ 23 - 17
src/tools/grep/tools.ts

@@ -1,40 +1,46 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-import { runRg } from "./cli"
-import { formatGrepResult } from "./utils"
+import { type ToolDefinition, tool } from '@opencode-ai/plugin/tool';
+import { runRg } from './cli';
+import { formatGrepResult } from './utils';
 
 export const grep: ToolDefinition = tool({
   description:
-    "Fast content search tool with safety limits (60s timeout, 10MB output). " +
-    "Searches file contents using regular expressions. " +
-    "Supports full regex syntax (eg. \"log.*Error\", \"function\\s+\\w+\", etc.). " +
-    "Filter files by pattern with the include parameter (eg. \"*.js\", \"*.{ts,tsx}\"). " +
-    "Returns file paths with matches sorted by modification time.",
+    'Fast content search tool with safety limits (60s timeout, 10MB output). ' +
+    'Searches file contents using regular expressions. ' +
+    'Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.). ' +
+    'Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}"). ' +
+    'Returns file paths with matches sorted by modification time.',
   args: {
-    pattern: tool.schema.string().describe("The regex pattern to search for in file contents"),
+    pattern: tool.schema
+      .string()
+      .describe('The regex pattern to search for in file contents'),
     include: tool.schema
       .string()
       .optional()
-      .describe('File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")'),
+      .describe(
+        'File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}")',
+      ),
     path: tool.schema
       .string()
       .optional()
-      .describe("The directory to search in. Defaults to the current working directory."),
+      .describe(
+        'The directory to search in. Defaults to the current working directory.',
+      ),
   },
   execute: async (args) => {
     try {
-      const globs = args.include ? [args.include] : undefined
-      const paths = args.path ? [args.path] : undefined
+      const globs = args.include ? [args.include] : undefined;
+      const paths = args.path ? [args.path] : undefined;
 
       const result = await runRg({
         pattern: args.pattern,
         paths,
         globs,
         context: 0,
-      })
+      });
 
-      return formatGrepResult(result)
+      return formatGrepResult(result);
     } catch (e) {
-      return `Error: ${e instanceof Error ? e.message : String(e)}`
+      return `Error: ${e instanceof Error ? e.message : String(e)}`;
     }
   },
-})
+});

+ 27 - 27
src/tools/grep/types.ts

@@ -1,38 +1,38 @@
 export interface GrepMatch {
-  file: string
-  line: number
-  text: string
+  file: string;
+  line: number;
+  text: string;
 }
 
 export interface GrepResult {
-  matches: GrepMatch[]
-  totalMatches: number
-  filesSearched: number
-  truncated: boolean
-  error?: string
+  matches: GrepMatch[];
+  totalMatches: number;
+  filesSearched: number;
+  truncated: boolean;
+  error?: string;
 }
 
 export interface CountResult {
-  file: string
-  count: number
+  file: string;
+  count: number;
 }
 
 export interface GrepOptions {
-  pattern: string
-  paths?: string[]
-  globs?: string[]
-  excludeGlobs?: string[]
-  context?: number
-  caseSensitive?: boolean
-  wholeWord?: boolean
-  fixedStrings?: boolean
-  multiline?: boolean
-  hidden?: boolean
-  noIgnore?: boolean
-  fileType?: string[]
-  maxDepth?: number
-  maxFilesize?: string
-  maxCount?: number
-  maxColumns?: number
-  timeout?: number
+  pattern: string;
+  paths?: string[];
+  globs?: string[];
+  excludeGlobs?: string[];
+  context?: number;
+  caseSensitive?: boolean;
+  wholeWord?: boolean;
+  fixedStrings?: boolean;
+  multiline?: boolean;
+  hidden?: boolean;
+  noIgnore?: boolean;
+  fileType?: string[];
+  maxDepth?: number;
+  maxFilesize?: string;
+  maxCount?: number;
+  maxColumns?: number;
+  timeout?: number;
 }

+ 14 - 14
src/tools/grep/utils.ts

@@ -1,37 +1,37 @@
-import type { GrepResult } from "./types"
+import type { GrepResult } from './types';
 
 export function formatGrepResult(result: GrepResult): string {
   if (result.error) {
-    return `Error: ${result.error}`
+    return `Error: ${result.error}`;
   }
 
   if (result.matches.length === 0) {
-    return "No matches found."
+    return 'No matches found.';
   }
 
-  const lines: string[] = []
+  const lines: string[] = [];
 
   // Group matches by file
-  const byFile = new Map<string, { line: number; text: string }[]>()
+  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 existing = byFile.get(match.file) || [];
+    existing.push({ line: match.line, text: match.text });
+    byFile.set(match.file, existing);
   }
 
   for (const [file, matches] of byFile) {
-    lines.push(`\n${file}:`)
+    lines.push(`\n${file}:`);
     for (const match of matches) {
-      lines.push(`  ${match.line}: ${match.text}`)
+      lines.push(`  ${match.line}: ${match.text}`);
     }
   }
 
-  const summary = `Found ${result.totalMatches} matches in ${result.filesSearched} files`
+  const summary = `Found ${result.totalMatches} matches in ${result.filesSearched} files`;
   if (result.truncated) {
-    lines.push(`\n${summary} (output truncated)`)
+    lines.push(`\n${summary} (output truncated)`);
   } else {
-    lines.push(`\n${summary}`)
+    lines.push(`\n${summary}`);
   }
 
-  return lines.join("\n")
+  return lines.join('\n');
 }

+ 11 - 12
src/tools/index.ts

@@ -1,20 +1,19 @@
-export { createBackgroundTools } from "./background";
+// AST-grep tools
+export { ast_grep_replace, ast_grep_search } from './ast-grep';
+export { createBackgroundTools } from './background';
+
+// Grep tool (ripgrep-based)
+export { grep } from './grep';
 export {
-  lsp_goto_definition,
-  lsp_find_references,
   lsp_diagnostics,
+  lsp_find_references,
+  lsp_goto_definition,
   lsp_rename,
   lspManager,
-} from "./lsp";
-
-// Grep tool (ripgrep-based)
-export { grep } from "./grep";
-
-// AST-grep tools
-export { ast_grep_search, ast_grep_replace } from "./ast-grep";
+} from './lsp';
 
 // Antigravity quota tool
-export { antigravity_quota } from "./quota";
+export { antigravity_quota } from './quota';
 
 // Skill tools
-export { createSkillTools, SkillMcpManager } from "./skill";
+export { createSkillTools, SkillMcpManager } from './skill';

+ 68 - 37
src/tools/lsp/client.test.ts

@@ -1,31 +1,38 @@
-import { expect, test, describe, mock, beforeEach, afterEach, spyOn } from "bun:test";
-import { PassThrough } from "stream";
+import {
+  afterEach,
+  beforeEach,
+  describe,
+  expect,
+  mock,
+  spyOn,
+  test,
+} from 'bun:test';
 
 // Mock spawn from bun
-mock.module("bun", () => ({
+mock.module('bun', () => ({
   spawn: mock().mockReturnValue({
     stdin: {
-        write: mock(),
-        end: mock(),
+      write: mock(),
+      end: mock(),
     },
     stdout: {
-        getReader: () => ({
-            read: () => Promise.resolve({ done: true, value: undefined })
-        })
+      getReader: () => ({
+        read: () => Promise.resolve({ done: true, value: undefined }),
+      }),
     },
     stderr: {
-        getReader: () => ({
-            read: () => Promise.resolve({ done: true, value: undefined })
-        })
+      getReader: () => ({
+        read: () => Promise.resolve({ done: true, value: undefined }),
+      }),
     },
     kill: mock(),
     exitCode: null,
-  })
+  }),
 }));
 
-import { lspManager, LSPClient } from "./client";
+import { LSPClient, lspManager } from './client';
 
-describe("LSPServerManager", () => {
+describe('LSPServerManager', () => {
   let startSpy: any;
   let initSpy: any;
   let aliveSpy: any;
@@ -33,10 +40,12 @@ describe("LSPServerManager", () => {
 
   beforeEach(async () => {
     await lspManager.stopAll();
-    startSpy = spyOn(LSPClient.prototype, "start").mockResolvedValue(undefined);
-    initSpy = spyOn(LSPClient.prototype, "initialize").mockResolvedValue(undefined);
-    aliveSpy = spyOn(LSPClient.prototype, "isAlive").mockReturnValue(true);
-    stopSpy = spyOn(LSPClient.prototype, "stop").mockResolvedValue(undefined);
+    startSpy = spyOn(LSPClient.prototype, 'start').mockResolvedValue(undefined);
+    initSpy = spyOn(LSPClient.prototype, 'initialize').mockResolvedValue(
+      undefined,
+    );
+    aliveSpy = spyOn(LSPClient.prototype, 'isAlive').mockReturnValue(true);
+    stopSpy = spyOn(LSPClient.prototype, 'stop').mockResolvedValue(undefined);
   });
 
   afterEach(async () => {
@@ -47,21 +56,29 @@ describe("LSPServerManager", () => {
     await lspManager.stopAll();
   });
 
-  test("getClient should create new client and reuse it", async () => {
-    const server = { id: "test", command: ["test-server"], extensions: [".test"] };
-    const root = "/root";
+  test('getClient should create new client and reuse it', async () => {
+    const server = {
+      id: 'test',
+      command: ['test-server'],
+      extensions: ['.test'],
+    };
+    const root = '/root';
 
     const client1 = await lspManager.getClient(root, server);
     expect(startSpy).toHaveBeenCalledTimes(1);
-    
+
     const client2 = await lspManager.getClient(root, server);
     expect(startSpy).toHaveBeenCalledTimes(1); // Should be reused
     expect(client1).toBe(client2);
   });
 
-  test("releaseClient should decrement ref count", async () => {
-    const server = { id: "test", command: ["test-server"], extensions: [".test"] };
-    const root = "/root";
+  test('releaseClient should decrement ref count', async () => {
+    const server = {
+      id: 'test',
+      command: ['test-server'],
+      extensions: ['.test'],
+    };
+    const root = '/root';
 
     await lspManager.getClient(root, server);
     const managed = (lspManager as any).clients.get(`${root}::${server.id}`);
@@ -71,25 +88,39 @@ describe("LSPServerManager", () => {
     expect(managed.refCount).toBe(0);
   });
 
-  test("cleanupIdleClients should remove idle clients", async () => {
-    const server = { id: "test", command: ["test-server"], extensions: [".test"] };
-    const root = "/root";
+  test('cleanupIdleClients should remove idle clients', async () => {
+    const server = {
+      id: 'test',
+      command: ['test-server'],
+      extensions: ['.test'],
+    };
+    const root = '/root';
 
     await lspManager.getClient(root, server);
     lspManager.releaseClient(root, server.id);
 
     const managed = (lspManager as any).clients.get(`${root}::${server.id}`);
-    managed.lastUsedAt = Date.now() - (6 * 60 * 1000);
+    managed.lastUsedAt = Date.now() - 6 * 60 * 1000;
 
     (lspManager as any).cleanupIdleClients();
 
-    expect((lspManager as any).clients.has(`${root}::${server.id}`)).toBe(false);
+    expect((lspManager as any).clients.has(`${root}::${server.id}`)).toBe(
+      false,
+    );
     expect(stopSpy).toHaveBeenCalled();
   });
 
-  test("stopAll should stop all clients", async () => {
-    await lspManager.getClient("/root1", { id: "s1", command: ["c1"], extensions: [".1"] });
-    await lspManager.getClient("/root2", { id: "s2", command: ["c2"], extensions: [".2"] });
+  test('stopAll should stop all clients', async () => {
+    await lspManager.getClient('/root1', {
+      id: 's1',
+      command: ['c1'],
+      extensions: ['.1'],
+    });
+    await lspManager.getClient('/root2', {
+      id: 's2',
+      command: ['c2'],
+      extensions: ['.2'],
+    });
 
     // Reset stopSpy because getClient might have called stop if there were old clients
     stopSpy.mockClear();
@@ -100,16 +131,16 @@ describe("LSPServerManager", () => {
     expect(stopSpy).toHaveBeenCalledTimes(2);
   });
 
-  test("should register process cleanup handlers", () => {
-    const onSpy = spyOn(process, "on");
+  test('should register process cleanup handlers', () => {
+    const onSpy = spyOn(process, 'on');
     // We need to create a new instance or trigger the registration
     // Since it's a singleton, we can just check if it was called during init
     // But it already happened. Let's check if the handlers are there.
     // Actually, we can just verify that it's intended to be called.
-    
+
     // For the sake of this test, let's just see if process.on was called with expected events
     // This might be tricky if it happened before we started spying.
-    
+
     // Instead, let's just verify that stopAll is exported and works, which we already did.
     expect(onSpy).toBeDefined();
     onSpy.mockRestore();

+ 220 - 189
src/tools/lsp/client.ts

@@ -1,113 +1,116 @@
 // LSP Client - Full implementation with connection pooling
 
-import { spawn, type Subprocess } from "bun"
-import { readFileSync } from "fs"
-import { extname, resolve } from "path"
-import { pathToFileURL } from "node:url"
-import { Readable, Writable } from "node:stream"
+import { readFileSync } from 'node:fs';
+import { extname, resolve } from 'node:path';
+import { Readable, Writable } from 'node:stream';
+import { pathToFileURL } from 'node:url';
+import { type Subprocess, spawn } from 'bun';
 import {
   createMessageConnection,
+  type MessageConnection,
   StreamMessageReader,
   StreamMessageWriter,
-  type MessageConnection,
-} from "vscode-jsonrpc/node"
-import { getLanguageId } from "./config"
-import type { Diagnostic, ResolvedServer } from "./types"
+} from 'vscode-jsonrpc/node';
+import { getLanguageId } from './config';
+import type { Diagnostic, ResolvedServer } from './types';
 
 interface ManagedClient {
-  client: LSPClient
-  lastUsedAt: number
-  refCount: number
-  initPromise?: Promise<void>
-  isInitializing: boolean
+  client: LSPClient;
+  lastUsedAt: number;
+  refCount: number;
+  initPromise?: Promise<void>;
+  isInitializing: boolean;
 }
 
 class LSPServerManager {
-  private static instance: LSPServerManager
-  private clients = new Map<string, ManagedClient>()
-  private cleanupInterval: ReturnType<typeof setInterval> | null = null
-  private readonly IDLE_TIMEOUT = 5 * 60 * 1000
+  private static instance: LSPServerManager;
+  private clients = new Map<string, ManagedClient>();
+  private cleanupInterval: ReturnType<typeof setInterval> | null = null;
+  private readonly IDLE_TIMEOUT = 5 * 60 * 1000;
 
   private constructor() {
-    this.startCleanupTimer()
-    this.registerProcessCleanup()
+    this.startCleanupTimer();
+    this.registerProcessCleanup();
   }
 
   private registerProcessCleanup(): void {
     const cleanup = () => {
       for (const [, managed] of this.clients) {
         try {
-          managed.client.stop()
+          managed.client.stop();
         } catch {}
       }
-      this.clients.clear()
+      this.clients.clear();
       if (this.cleanupInterval) {
-        clearInterval(this.cleanupInterval)
-        this.cleanupInterval = null
+        clearInterval(this.cleanupInterval);
+        this.cleanupInterval = null;
       }
-    }
-
-    process.on("exit", cleanup)
-    process.on("SIGINT", () => {
-      cleanup()
-      process.exit(0)
-    })
-    process.on("SIGTERM", () => {
-      cleanup()
-      process.exit(0)
-    })
+    };
+
+    process.on('exit', cleanup);
+    process.on('SIGINT', () => {
+      cleanup();
+      process.exit(0);
+    });
+    process.on('SIGTERM', () => {
+      cleanup();
+      process.exit(0);
+    });
   }
 
   static getInstance(): LSPServerManager {
     if (!LSPServerManager.instance) {
-      LSPServerManager.instance = new LSPServerManager()
+      LSPServerManager.instance = new LSPServerManager();
     }
-    return LSPServerManager.instance
+    return LSPServerManager.instance;
   }
 
   private getKey(root: string, serverId: string): string {
-    return `${root}::${serverId}`
+    return `${root}::${serverId}`;
   }
 
   private startCleanupTimer(): void {
-    if (this.cleanupInterval) return
+    if (this.cleanupInterval) return;
     this.cleanupInterval = setInterval(() => {
-      this.cleanupIdleClients()
-    }, 60000)
+      this.cleanupIdleClients();
+    }, 60000);
   }
 
   private cleanupIdleClients(): void {
-    const now = Date.now()
+    const now = Date.now();
     for (const [key, managed] of this.clients) {
-      if (managed.refCount === 0 && now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
-        managed.client.stop()
-        this.clients.delete(key)
+      if (
+        managed.refCount === 0 &&
+        now - managed.lastUsedAt > this.IDLE_TIMEOUT
+      ) {
+        managed.client.stop();
+        this.clients.delete(key);
       }
     }
   }
 
   async getClient(root: string, server: ResolvedServer): Promise<LSPClient> {
-    const key = this.getKey(root, server.id)
+    const key = this.getKey(root, server.id);
 
-    let managed = this.clients.get(key)
+    const managed = this.clients.get(key);
     if (managed) {
       if (managed.initPromise) {
-        await managed.initPromise
+        await managed.initPromise;
       }
       if (managed.client.isAlive()) {
-        managed.refCount++
-        managed.lastUsedAt = Date.now()
-        return managed.client
+        managed.refCount++;
+        managed.lastUsedAt = Date.now();
+        return managed.client;
       }
-      await managed.client.stop()
-      this.clients.delete(key)
+      await managed.client.stop();
+      this.clients.delete(key);
     }
 
-    const client = new LSPClient(root, server)
+    const client = new LSPClient(root, server);
     const initPromise = (async () => {
-      await client.start()
-      await client.initialize()
-    })()
+      await client.start();
+      await client.initialize();
+    })();
 
     this.clients.set(key, {
       client,
@@ -115,188 +118,197 @@ class LSPServerManager {
       refCount: 1,
       initPromise,
       isInitializing: true,
-    })
+    });
 
     try {
-      await initPromise
-      const m = this.clients.get(key)
+      await initPromise;
+      const m = this.clients.get(key);
       if (m) {
-        m.initPromise = undefined
-        m.isInitializing = false
+        m.initPromise = undefined;
+        m.isInitializing = false;
       }
     } catch (err) {
-      this.clients.delete(key)
-      throw err
+      this.clients.delete(key);
+      throw err;
     }
 
-    return client
+    return client;
   }
 
   releaseClient(root: string, serverId: string): void {
-    const key = this.getKey(root, serverId)
-    const managed = this.clients.get(key)
+    const key = this.getKey(root, serverId);
+    const managed = this.clients.get(key);
     if (managed && managed.refCount > 0) {
-      managed.refCount--
-      managed.lastUsedAt = Date.now()
+      managed.refCount--;
+      managed.lastUsedAt = Date.now();
     }
   }
 
   isServerInitializing(root: string, serverId: string): boolean {
-    const key = this.getKey(root, serverId)
-    const managed = this.clients.get(key)
-    return managed?.isInitializing ?? false
+    const key = this.getKey(root, serverId);
+    const managed = this.clients.get(key);
+    return managed?.isInitializing ?? false;
   }
 
   async stopAll(): Promise<void> {
     for (const [, managed] of this.clients) {
-      await managed.client.stop()
+      await managed.client.stop();
     }
-    this.clients.clear()
+    this.clients.clear();
     if (this.cleanupInterval) {
-      clearInterval(this.cleanupInterval)
-      this.cleanupInterval = null
+      clearInterval(this.cleanupInterval);
+      this.cleanupInterval = null;
     }
   }
 }
 
-export const lspManager = LSPServerManager.getInstance()
+export const lspManager = LSPServerManager.getInstance();
 
 export class LSPClient {
-  private proc: Subprocess<"pipe", "pipe", "pipe"> | null = null
-  private connection: MessageConnection | null = null
-  private openedFiles = new Set<string>()
-  private stderrBuffer: string[] = []
-  private processExited = false
-  private diagnosticsStore = new Map<string, Diagnostic[]>()
+  private proc: Subprocess<'pipe', 'pipe', 'pipe'> | null = null;
+  private connection: MessageConnection | null = null;
+  private openedFiles = new Set<string>();
+  private stderrBuffer: string[] = [];
+  private processExited = false;
+  private diagnosticsStore = new Map<string, Diagnostic[]>();
 
   constructor(
     private root: string,
-    private server: ResolvedServer
+    private server: ResolvedServer,
   ) {}
 
   async start(): Promise<void> {
     this.proc = spawn(this.server.command, {
-      stdin: "pipe",
-      stdout: "pipe",
-      stderr: "pipe",
+      stdin: 'pipe',
+      stdout: 'pipe',
+      stderr: 'pipe',
       cwd: this.root,
       env: {
         ...process.env,
         ...this.server.env,
       },
-    })
+    });
 
     if (!this.proc) {
-      throw new Error(`Failed to spawn LSP server: ${this.server.command.join(" ")}`)
+      throw new Error(
+        `Failed to spawn LSP server: ${this.server.command.join(' ')}`,
+      );
     }
 
-    this.startStderrReading()
+    this.startStderrReading();
 
     // Create JSON-RPC connection
-    const stdoutReader = this.proc.stdout.getReader()
+    const stdoutReader = this.proc.stdout.getReader();
     const nodeReadable = new Readable({
       async read() {
         try {
-          const { done, value } = await stdoutReader.read()
+          const { done, value } = await stdoutReader.read();
           if (done) {
-            this.push(null)
+            this.push(null);
           } else {
-            this.push(value)
+            this.push(value);
           }
         } catch (err) {
-          this.destroy(err as Error)
+          this.destroy(err as Error);
         }
       },
-    })
+    });
 
-    const stdin = this.proc.stdin
+    const stdin = this.proc.stdin;
     const nodeWritable = new Writable({
-      write(chunk, encoding, callback) {
+      write(chunk, _encoding, callback) {
         try {
-          stdin.write(chunk)
-          callback()
+          stdin.write(chunk);
+          callback();
         } catch (err) {
-          callback(err as Error)
+          callback(err as Error);
         }
       },
       final(callback) {
         try {
-          stdin.end()
-          callback()
+          stdin.end();
+          callback();
         } catch (err) {
-          callback(err as Error)
+          callback(err as Error);
         }
       },
-    })
-
-    this.connection = createMessageConnection(new StreamMessageReader(nodeReadable), new StreamMessageWriter(nodeWritable))
-
-    this.connection.onNotification("textDocument/publishDiagnostics", (params: any) => {
-      if (params.uri) {
-        this.diagnosticsStore.set(params.uri, params.diagnostics ?? [])
-      }
-    })
+    });
+
+    this.connection = createMessageConnection(
+      new StreamMessageReader(nodeReadable),
+      new StreamMessageWriter(nodeWritable),
+    );
+
+    this.connection.onNotification(
+      'textDocument/publishDiagnostics',
+      (params: any) => {
+        if (params.uri) {
+          this.diagnosticsStore.set(params.uri, params.diagnostics ?? []);
+        }
+      },
+    );
 
-    this.connection.onRequest("workspace/configuration", (params: any) => {
-      const items = params.items ?? []
+    this.connection.onRequest('workspace/configuration', (params: any) => {
+      const items = params.items ?? [];
       return items.map((item: any) => {
-        if (item.section === "json") return { validate: { enable: true } }
-        return {}
-      })
-    })
+        if (item.section === 'json') return { validate: { enable: true } };
+        return {};
+      });
+    });
 
-    this.connection.onRequest("client/registerCapability", () => null)
-    this.connection.onRequest("window/workDoneProgress/create", () => null)
+    this.connection.onRequest('client/registerCapability', () => null);
+    this.connection.onRequest('window/workDoneProgress/create', () => null);
 
     this.connection.onClose(() => {
-      this.processExited = true
-    })
+      this.processExited = true;
+    });
 
-    this.connection.listen()
+    this.connection.listen();
 
-    await new Promise((resolve) => setTimeout(resolve, 100))
+    await new Promise((resolve) => setTimeout(resolve, 100));
 
     if (this.proc.exitCode !== null) {
-      const stderr = this.stderrBuffer.join("\n")
+      const stderr = this.stderrBuffer.join('\n');
       throw new Error(
-        `LSP server exited immediately with code ${this.proc.exitCode}` + (stderr ? `\nstderr: ${stderr}` : "")
-      )
+        `LSP server exited immediately with code ${this.proc.exitCode}` +
+          (stderr ? `\nstderr: ${stderr}` : ''),
+      );
     }
   }
 
   private startStderrReading(): void {
-    if (!this.proc) return
+    if (!this.proc) return;
 
-    const reader = this.proc.stderr.getReader()
+    const reader = this.proc.stderr.getReader();
     const read = async () => {
-      const decoder = new TextDecoder()
+      const decoder = new TextDecoder();
       try {
         while (true) {
-          const { done, value } = await reader.read()
-          if (done) break
-          const text = decoder.decode(value)
-          this.stderrBuffer.push(text)
+          const { done, value } = await reader.read();
+          if (done) break;
+          const text = decoder.decode(value);
+          this.stderrBuffer.push(text);
           if (this.stderrBuffer.length > 100) {
-            this.stderrBuffer.shift()
+            this.stderrBuffer.shift();
           }
         }
       } catch {}
-    }
-    read()
+    };
+    read();
   }
 
   async initialize(): Promise<void> {
-    if (!this.connection) throw new Error("LSP connection not established")
+    if (!this.connection) throw new Error('LSP connection not established');
 
-    const rootUri = pathToFileURL(this.root).href
-    await this.connection.sendRequest("initialize", {
+    const rootUri = pathToFileURL(this.root).href;
+    await this.connection.sendRequest('initialize', {
       processId: process.pid,
       rootUri,
       rootPath: this.root,
-      workspaceFolders: [{ uri: rootUri, name: "workspace" }],
+      workspaceFolders: [{ uri: rootUri, name: 'workspace' }],
       capabilities: {
         textDocument: {
-          hover: { contentFormat: ["markdown", "plaintext"] },
+          hover: { contentFormat: ['markdown', 'plaintext'] },
           definition: { linkSupport: true },
           references: {},
           documentSymbol: { hierarchicalDocumentSymbolSupport: true },
@@ -316,95 +328,114 @@ export class LSPClient {
         },
       },
       ...this.server.initialization,
-    })
-    this.connection.sendNotification("initialized")
-    await new Promise((r) => setTimeout(r, 300))
+    });
+    this.connection.sendNotification('initialized');
+    await new Promise((r) => setTimeout(r, 300));
   }
 
   async openFile(filePath: string): Promise<void> {
-    const absPath = resolve(filePath)
-    if (this.openedFiles.has(absPath)) return
+    const absPath = resolve(filePath);
+    if (this.openedFiles.has(absPath)) return;
 
-    const text = readFileSync(absPath, "utf-8")
-    const ext = extname(absPath)
-    const languageId = getLanguageId(ext)
+    const text = readFileSync(absPath, 'utf-8');
+    const ext = extname(absPath);
+    const languageId = getLanguageId(ext);
 
-    this.connection?.sendNotification("textDocument/didOpen", {
+    this.connection?.sendNotification('textDocument/didOpen', {
       textDocument: {
         uri: pathToFileURL(absPath).href,
         languageId,
         version: 1,
         text,
       },
-    })
-    this.openedFiles.add(absPath)
+    });
+    this.openedFiles.add(absPath);
 
-    await new Promise((r) => setTimeout(r, 1000))
+    await new Promise((r) => setTimeout(r, 1000));
   }
 
-  async definition(filePath: string, line: number, character: number): Promise<unknown> {
-    const absPath = resolve(filePath)
-    await this.openFile(absPath)
-    return this.connection?.sendRequest("textDocument/definition", {
+  async definition(
+    filePath: string,
+    line: number,
+    character: number,
+  ): Promise<unknown> {
+    const absPath = resolve(filePath);
+    await this.openFile(absPath);
+    return this.connection?.sendRequest('textDocument/definition', {
       textDocument: { uri: pathToFileURL(absPath).href },
       position: { line: line - 1, character },
-    })
+    });
   }
 
-  async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<unknown> {
-    const absPath = resolve(filePath)
-    await this.openFile(absPath)
-    return this.connection?.sendRequest("textDocument/references", {
+  async references(
+    filePath: string,
+    line: number,
+    character: number,
+    includeDeclaration = true,
+  ): Promise<unknown> {
+    const absPath = resolve(filePath);
+    await this.openFile(absPath);
+    return this.connection?.sendRequest('textDocument/references', {
       textDocument: { uri: pathToFileURL(absPath).href },
       position: { line: line - 1, character },
       context: { includeDeclaration },
-    })
+    });
   }
 
   async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
-    const absPath = resolve(filePath)
-    const uri = pathToFileURL(absPath).href
-    await this.openFile(absPath)
-    await new Promise((r) => setTimeout(r, 500))
+    const absPath = resolve(filePath);
+    const uri = pathToFileURL(absPath).href;
+    await this.openFile(absPath);
+    await new Promise((r) => setTimeout(r, 500));
 
     try {
-      const result = await this.connection?.sendRequest("textDocument/diagnostic", {
-        textDocument: { uri },
-      })
-      if (result && typeof result === "object" && "items" in result) {
-        return result as { items: Diagnostic[] }
+      const result = await this.connection?.sendRequest(
+        'textDocument/diagnostic',
+        {
+          textDocument: { uri },
+        },
+      );
+      if (result && typeof result === 'object' && 'items' in result) {
+        return result as { items: Diagnostic[] };
       }
     } catch {}
 
-    return { items: this.diagnosticsStore.get(uri) ?? [] }
+    return { items: this.diagnosticsStore.get(uri) ?? [] };
   }
 
-  async rename(filePath: string, line: number, character: number, newName: string): Promise<unknown> {
-    const absPath = resolve(filePath)
-    await this.openFile(absPath)
-    return this.connection?.sendRequest("textDocument/rename", {
+  async rename(
+    filePath: string,
+    line: number,
+    character: number,
+    newName: string,
+  ): Promise<unknown> {
+    const absPath = resolve(filePath);
+    await this.openFile(absPath);
+    return this.connection?.sendRequest('textDocument/rename', {
       textDocument: { uri: pathToFileURL(absPath).href },
       position: { line: line - 1, character },
       newName,
-    })
+    });
   }
 
   isAlive(): boolean {
-    return this.proc !== null && !this.processExited && this.proc.exitCode === null
+    return (
+      this.proc !== null && !this.processExited && this.proc.exitCode === null
+    );
   }
 
   async stop(): Promise<void> {
     try {
       if (this.connection) {
-        await this.connection.sendRequest("shutdown")
-        this.connection.sendNotification("exit")
-        this.connection.dispose()
+        await this.connection.sendRequest('shutdown');
+        this.connection.sendNotification('exit');
+        this.connection.dispose();
       }
     } catch {}
-    this.proc?.kill()
-    this.proc = null
-    this.connection = null
-    this.processExited = true
-    this.diagnosticsStore.clear()
+    this.proc?.kill();
+    this.proc = null;
+    this.connection = null;
+    this.processExited = true;
+    this.diagnosticsStore.clear();
   }
 }

+ 79 - 57
src/tools/lsp/config.test.ts

@@ -1,96 +1,118 @@
-import { expect, test, describe, mock, beforeEach } from "bun:test";
-import { join } from "path";
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { join } from 'node:path';
 
 // Mock fs and os BEFORE importing the modules that use them
-mock.module("fs", () => ({
+mock.module('fs', () => ({
   existsSync: mock(() => false),
 }));
 
-mock.module("os", () => ({
-  homedir: () => "/home/user",
+mock.module('os', () => ({
+  homedir: () => '/home/user',
 }));
 
+import { existsSync } from 'node:fs';
 // Now import the code to test
-import { findServerForExtension, isServerInstalled } from "./config";
-import { existsSync } from "fs";
+import { findServerForExtension, isServerInstalled } from './config';
 
-describe("config", () => {
+describe('config', () => {
   beforeEach(() => {
     (existsSync as any).mockClear();
     (existsSync as any).mockImplementation(() => false);
   });
 
-  describe("isServerInstalled", () => {
-    test("should return false if command is empty", () => {
+  describe('isServerInstalled', () => {
+    test('should return false if command is empty', () => {
       expect(isServerInstalled([])).toBe(false);
     });
 
-    test("should detect absolute paths", () => {
-      (existsSync as any).mockImplementation((path: string) => path === "/usr/bin/lsp-server");
-      expect(isServerInstalled(["/usr/bin/lsp-server"])).toBe(true);
-      expect(isServerInstalled(["/usr/bin/missing"])).toBe(false);
+    test('should detect absolute paths', () => {
+      (existsSync as any).mockImplementation(
+        (path: string) => path === '/usr/bin/lsp-server',
+      );
+      expect(isServerInstalled(['/usr/bin/lsp-server'])).toBe(true);
+      expect(isServerInstalled(['/usr/bin/missing'])).toBe(false);
     });
 
-    test("should detect server in PATH", () => {
+    test('should detect server in PATH', () => {
       const originalPath = process.env.PATH;
-      process.env.PATH = "/usr/local/bin:/usr/bin";
-      
-      (existsSync as any).mockImplementation((path: string) => path === join("/usr/bin", "typescript-language-server"));
-      
-      expect(isServerInstalled(["typescript-language-server"])).toBe(true);
-      
+      process.env.PATH = '/usr/local/bin:/usr/bin';
+
+      (existsSync as any).mockImplementation(
+        (path: string) =>
+          path === join('/usr/bin', 'typescript-language-server'),
+      );
+
+      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
+
       process.env.PATH = originalPath;
     });
 
-    test("should detect server in local node_modules", () => {
+    test('should detect server in local node_modules', () => {
       const cwd = process.cwd();
-      const localBin = join(cwd, "node_modules", ".bin", "typescript-language-server");
-      
-      (existsSync as any).mockImplementation((path: string) => path === localBin);
-      
-      expect(isServerInstalled(["typescript-language-server"])).toBe(true);
+      const localBin = join(
+        cwd,
+        'node_modules',
+        '.bin',
+        'typescript-language-server',
+      );
+
+      (existsSync as any).mockImplementation(
+        (path: string) => path === localBin,
+      );
+
+      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
     });
 
-    test("should detect server in global opencode bin", () => {
-      const globalBin = join("/home/user", ".config", "opencode", "bin", "typescript-language-server");
-      
-      (existsSync as any).mockImplementation((path: string) => path === globalBin);
-      
-      expect(isServerInstalled(["typescript-language-server"])).toBe(true);
+    test('should detect server in global opencode bin', () => {
+      const globalBin = join(
+        '/home/user',
+        '.config',
+        'opencode',
+        'bin',
+        'typescript-language-server',
+      );
+
+      (existsSync as any).mockImplementation(
+        (path: string) => path === globalBin,
+      );
+
+      expect(isServerInstalled(['typescript-language-server'])).toBe(true);
     });
   });
 
-  describe("findServerForExtension", () => {
-    test("should return found for .ts extension if installed", () => {
+  describe('findServerForExtension', () => {
+    test('should return found for .ts extension if installed', () => {
+      (existsSync as any).mockReturnValue(true);
+      const result = findServerForExtension('.ts');
+      expect(result.status).toBe('found');
+      if (result.status === 'found') {
+        expect(result.server.id).toBe('typescript');
+      }
+    });
+
+    test('should return found for .py extension if installed (prefers basedpyright)', () => {
       (existsSync as any).mockReturnValue(true);
-      const result = findServerForExtension(".ts");
-      expect(result.status).toBe("found");
-      if (result.status === "found") {
-        expect(result.server.id).toBe("typescript");
+      const result = findServerForExtension('.py');
+      expect(result.status).toBe('found');
+      if (result.status === 'found') {
+        expect(result.server.id).toBe('basedpyright');
       }
     });
 
-    test("should return found for .py extension if installed (prefers basedpyright)", () => {
-        (existsSync as any).mockReturnValue(true);
-        const result = findServerForExtension(".py");
-        expect(result.status).toBe("found");
-        if (result.status === "found") {
-          expect(result.server.id).toBe("basedpyright");
-        }
-      });
-
-    test("should return not_configured for unknown extension", () => {
-      const result = findServerForExtension(".unknown");
-      expect(result.status).toBe("not_configured");
+    test('should return not_configured for unknown extension', () => {
+      const result = findServerForExtension('.unknown');
+      expect(result.status).toBe('not_configured');
     });
 
-    test("should return not_installed if server not in PATH", () => {
+    test('should return not_installed if server not in PATH', () => {
       (existsSync as any).mockReturnValue(false);
-      const result = findServerForExtension(".ts");
-      expect(result.status).toBe("not_installed");
-      if (result.status === "not_installed") {
-        expect(result.server.id).toBe("typescript");
-        expect(result.installHint).toContain("npm install -g typescript-language-server");
+      const result = findServerForExtension('.ts');
+      expect(result.status).toBe('not_installed');
+      if (result.status === 'not_installed') {
+        expect(result.server.id).toBe('typescript');
+        expect(result.installHint).toContain(
+          'npm install -g typescript-language-server',
+        );
       }
     });
   });

+ 30 - 28
src/tools/lsp/config.ts

@@ -1,10 +1,10 @@
 // Simplified LSP config - just PATH lookup, no multi-tier config merging
 
-import { existsSync } from "fs"
-import { join } from "path"
-import { homedir } from "os"
-import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
-import type { ResolvedServer, ServerLookupResult } from "./types"
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from './constants';
+import type { ResolvedServer, ServerLookupResult } from './types';
 
 export function findServerForExtension(ext: string): ServerLookupResult {
   // Find matching server
@@ -16,63 +16,65 @@ export function findServerForExtension(ext: string): ServerLookupResult {
         extensions: config.extensions,
         env: config.env,
         initialization: config.initialization,
-      }
+      };
 
       if (isServerInstalled(config.command)) {
-        return { status: "found", server }
+        return { status: 'found', server };
       }
 
       return {
-        status: "not_installed",
+        status: 'not_installed',
         server,
-        installHint: LSP_INSTALL_HINTS[id] || `Install '${config.command[0]}' and add to PATH`,
-      }
+        installHint:
+          LSP_INSTALL_HINTS[id] ||
+          `Install '${config.command[0]}' and add to PATH`,
+      };
     }
   }
 
-  return { status: "not_configured", extension: ext }
+  return { status: 'not_configured', extension: ext };
 }
 
 export function getLanguageId(ext: string): string {
-  return EXT_TO_LANG[ext] || "plaintext"
+  return EXT_TO_LANG[ext] || 'plaintext';
 }
 
 export function isServerInstalled(command: string[]): boolean {
-  if (command.length === 0) return false
+  if (command.length === 0) return false;
 
-  const cmd = command[0]
+  const cmd = command[0];
 
   // Absolute paths
-  if (cmd.includes("/") || cmd.includes("\\")) {
-    return existsSync(cmd)
+  if (cmd.includes('/') || cmd.includes('\\')) {
+    return existsSync(cmd);
   }
 
-  const isWindows = process.platform === "win32"
-  const ext = isWindows ? ".exe" : ""
+  const isWindows = process.platform === 'win32';
+  const ext = isWindows ? '.exe' : '';
 
   // Check PATH
-  const pathEnv = process.env.PATH || ""
-  const pathSeparator = isWindows ? ";" : ":"
-  const paths = pathEnv.split(pathSeparator)
+  const pathEnv = process.env.PATH || '';
+  const pathSeparator = isWindows ? ';' : ':';
+  const paths = pathEnv.split(pathSeparator);
 
   for (const p of paths) {
     if (existsSync(join(p, cmd)) || existsSync(join(p, cmd + ext))) {
-      return true
+      return true;
     }
   }
 
   // Check local node_modules
-  const cwd = process.cwd()
-  const localBin = join(cwd, "node_modules", ".bin", cmd)
+  const cwd = process.cwd();
+  const localBin = join(cwd, 'node_modules', '.bin', cmd);
   if (existsSync(localBin) || existsSync(localBin + ext)) {
-    return true
+    return true;
   }
 
   // Check global opencode bin
-  const globalBin = join(homedir(), ".config", "opencode", "bin", cmd)
+  const globalBin = join(homedir(), '.config', 'opencode', 'bin', cmd);
   if (existsSync(globalBin) || existsSync(globalBin + ext)) {
-    return true
+    return true;
   }
 
-  return false
+  return false;
 }

+ 111 - 102
src/tools/lsp/constants.ts

@@ -1,146 +1,155 @@
 // Slim LSP constants - only essential languages
 
-import type { LSPServerConfig } from "./types"
+import type { LSPServerConfig } from './types';
 
 export const SYMBOL_KIND_MAP: Record<number, string> = {
-  1: "File",
-  2: "Module",
-  3: "Namespace",
-  4: "Package",
-  5: "Class",
-  6: "Method",
-  7: "Property",
-  8: "Field",
-  9: "Constructor",
-  10: "Enum",
-  11: "Interface",
-  12: "Function",
-  13: "Variable",
-  14: "Constant",
-  15: "String",
-  16: "Number",
-  17: "Boolean",
-  18: "Array",
-  19: "Object",
-  20: "Key",
-  21: "Null",
-  22: "EnumMember",
-  23: "Struct",
-  24: "Event",
-  25: "Operator",
-  26: "TypeParameter",
-}
+  1: 'File',
+  2: 'Module',
+  3: 'Namespace',
+  4: 'Package',
+  5: 'Class',
+  6: 'Method',
+  7: 'Property',
+  8: 'Field',
+  9: 'Constructor',
+  10: 'Enum',
+  11: 'Interface',
+  12: 'Function',
+  13: 'Variable',
+  14: 'Constant',
+  15: 'String',
+  16: 'Number',
+  17: 'Boolean',
+  18: 'Array',
+  19: 'Object',
+  20: 'Key',
+  21: 'Null',
+  22: 'EnumMember',
+  23: 'Struct',
+  24: 'Event',
+  25: 'Operator',
+  26: 'TypeParameter',
+};
 
 export const SEVERITY_MAP: Record<number, string> = {
-  1: "error",
-  2: "warning",
-  3: "information",
-  4: "hint",
-}
+  1: 'error',
+  2: 'warning',
+  3: 'information',
+  4: 'hint',
+};
 
-export const DEFAULT_MAX_REFERENCES = 200
-export const DEFAULT_MAX_DIAGNOSTICS = 200
+export const DEFAULT_MAX_REFERENCES = 200;
+export const DEFAULT_MAX_DIAGNOSTICS = 200;
 
 // Slim server list - common languages + popular frontend
-export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
+export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, 'id'>> = {
   // JavaScript/TypeScript ecosystem
   typescript: {
-    command: ["typescript-language-server", "--stdio"],
-    extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"],
+    command: ['typescript-language-server', '--stdio'],
+    extensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts'],
   },
   vue: {
-    command: ["vue-language-server", "--stdio"],
-    extensions: [".vue"],
+    command: ['vue-language-server', '--stdio'],
+    extensions: ['.vue'],
   },
   svelte: {
-    command: ["svelteserver", "--stdio"],
-    extensions: [".svelte"],
+    command: ['svelteserver', '--stdio'],
+    extensions: ['.svelte'],
   },
   astro: {
-    command: ["astro-ls", "--stdio"],
-    extensions: [".astro"],
+    command: ['astro-ls', '--stdio'],
+    extensions: ['.astro'],
   },
   eslint: {
-    command: ["vscode-eslint-language-server", "--stdio"],
-    extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte"],
+    command: ['vscode-eslint-language-server', '--stdio'],
+    extensions: [
+      '.ts',
+      '.tsx',
+      '.js',
+      '.jsx',
+      '.mjs',
+      '.cjs',
+      '.vue',
+      '.svelte',
+    ],
   },
   tailwindcss: {
-    command: ["tailwindcss-language-server", "--stdio"],
-    extensions: [".html", ".jsx", ".tsx", ".vue", ".svelte", ".astro"],
+    command: ['tailwindcss-language-server', '--stdio'],
+    extensions: ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'],
   },
   // Backend languages
   gopls: {
-    command: ["gopls"],
-    extensions: [".go"],
+    command: ['gopls'],
+    extensions: ['.go'],
   },
   rust: {
-    command: ["rust-analyzer"],
-    extensions: [".rs"],
+    command: ['rust-analyzer'],
+    extensions: ['.rs'],
   },
   basedpyright: {
-    command: ["basedpyright-langserver", "--stdio"],
-    extensions: [".py", ".pyi"],
+    command: ['basedpyright-langserver', '--stdio'],
+    extensions: ['.py', '.pyi'],
   },
   pyright: {
-    command: ["pyright-langserver", "--stdio"],
-    extensions: [".py", ".pyi"],
+    command: ['pyright-langserver', '--stdio'],
+    extensions: ['.py', '.pyi'],
   },
   clangd: {
-    command: ["clangd", "--background-index"],
-    extensions: [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"],
+    command: ['clangd', '--background-index'],
+    extensions: ['.c', '.cpp', '.cc', '.cxx', '.h', '.hpp'],
   },
   zls: {
-    command: ["zls"],
-    extensions: [".zig"],
+    command: ['zls'],
+    extensions: ['.zig'],
   },
-}
+};
 
 export const LSP_INSTALL_HINTS: Record<string, string> = {
-  typescript: "npm install -g typescript-language-server typescript",
-  vue: "npm install -g @vue/language-server",
-  svelte: "npm install -g svelte-language-server",
-  astro: "npm install -g @astrojs/language-server",
-  eslint: "npm install -g vscode-langservers-extracted",
-  tailwindcss: "npm install -g @tailwindcss/language-server",
-  gopls: "go install golang.org/x/tools/gopls@latest",
-  rust: "rustup component add rust-analyzer",
-  basedpyright: "pip install basedpyright",
-  pyright: "pip install pyright",
-  clangd: "See https://clangd.llvm.org/installation",
-  zls: "See https://github.com/zigtools/zls",
-}
+  typescript: 'npm install -g typescript-language-server typescript',
+  vue: 'npm install -g @vue/language-server',
+  svelte: 'npm install -g svelte-language-server',
+  astro: 'npm install -g @astrojs/language-server',
+  eslint: 'npm install -g vscode-langservers-extracted',
+  tailwindcss: 'npm install -g @tailwindcss/language-server',
+  gopls: 'go install golang.org/x/tools/gopls@latest',
+  rust: 'rustup component add rust-analyzer',
+  basedpyright: 'pip install basedpyright',
+  pyright: 'pip install pyright',
+  clangd: 'See https://clangd.llvm.org/installation',
+  zls: 'See https://github.com/zigtools/zls',
+};
 
 // Extension to language ID mapping
 export const EXT_TO_LANG: Record<string, string> = {
   // TypeScript/JavaScript
-  ".ts": "typescript",
-  ".tsx": "typescriptreact",
-  ".mts": "typescript",
-  ".cts": "typescript",
-  ".js": "javascript",
-  ".jsx": "javascriptreact",
-  ".mjs": "javascript",
-  ".cjs": "javascript",
+  '.ts': 'typescript',
+  '.tsx': 'typescriptreact',
+  '.mts': 'typescript',
+  '.cts': 'typescript',
+  '.js': 'javascript',
+  '.jsx': 'javascriptreact',
+  '.mjs': 'javascript',
+  '.cjs': 'javascript',
   // Frontend frameworks
-  ".vue": "vue",
-  ".svelte": "svelte",
-  ".astro": "astro",
+  '.vue': 'vue',
+  '.svelte': 'svelte',
+  '.astro': 'astro',
   // Web
-  ".html": "html",
-  ".css": "css",
-  ".scss": "scss",
-  ".less": "less",
-  ".json": "json",
+  '.html': 'html',
+  '.css': 'css',
+  '.scss': 'scss',
+  '.less': 'less',
+  '.json': 'json',
   // Backend
-  ".go": "go",
-  ".rs": "rust",
-  ".py": "python",
-  ".pyi": "python",
-  ".c": "c",
-  ".cpp": "cpp",
-  ".cc": "cpp",
-  ".cxx": "cpp",
-  ".h": "c",
-  ".hpp": "cpp",
-  ".zig": "zig",
-}
+  '.go': 'go',
+  '.rs': 'rust',
+  '.py': 'python',
+  '.pyi': 'python',
+  '.c': 'c',
+  '.cpp': 'cpp',
+  '.cc': 'cpp',
+  '.cxx': 'cpp',
+  '.h': 'c',
+  '.hpp': 'cpp',
+  '.zig': 'zig',
+};

+ 7 - 7
src/tools/lsp/index.ts

@@ -1,18 +1,18 @@
 // LSP Module - Explicit exports
 
-export { lspManager } from "./client"
+export { lspManager } from './client';
 export {
-  lsp_goto_definition,
-  lsp_find_references,
   lsp_diagnostics,
+  lsp_find_references,
+  lsp_goto_definition,
   lsp_rename,
-} from "./tools"
+} from './tools';
 
 // Re-export types for external use
 export type {
-  LSPServerConfig,
-  ResolvedServer,
   Diagnostic,
   Location,
+  LSPServerConfig,
+  ResolvedServer,
   WorkspaceEdit,
-} from "./types"
+} from './types';

+ 99 - 72
src/tools/lsp/tools.ts

@@ -1,60 +1,69 @@
 // LSP Tools - 4 essential tools only
 
-import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
-import { DEFAULT_MAX_REFERENCES, DEFAULT_MAX_DIAGNOSTICS } from "./constants"
+import { type ToolDefinition, tool } from '@opencode-ai/plugin/tool';
+import { DEFAULT_MAX_DIAGNOSTICS, DEFAULT_MAX_REFERENCES } from './constants';
+import type {
+  Diagnostic,
+  Location,
+  LocationLink,
+  WorkspaceEdit,
+} from './types';
 import {
-  withLspClient,
-  formatLocation,
-  formatDiagnostic,
-  filterDiagnosticsBySeverity,
   applyWorkspaceEdit,
+  filterDiagnosticsBySeverity,
   formatApplyResult,
-} from "./utils"
-import type { Location, LocationLink, Diagnostic, WorkspaceEdit } from "./types"
+  formatDiagnostic,
+  formatLocation,
+  withLspClient,
+} from './utils';
 
 const formatError = (e: unknown): string =>
-  `Error: ${e instanceof Error ? e.message : String(e)}`
+  `Error: ${e instanceof Error ? e.message : String(e)}`;
 
 export const lsp_goto_definition: ToolDefinition = tool({
-  description: "Jump to symbol definition. Find WHERE something is defined.",
+  description: 'Jump to symbol definition. Find WHERE something is defined.',
   args: {
-    filePath: tool.schema.string().describe("Absolute path to the file"),
-    line: tool.schema.number().min(1).describe("1-based line number"),
-    character: tool.schema.number().min(0).describe("0-based character offset"),
+    filePath: tool.schema.string().describe('Absolute path to the file'),
+    line: tool.schema.number().min(1).describe('1-based line number'),
+    character: tool.schema.number().min(0).describe('0-based character offset'),
   },
   execute: async (args) => {
     try {
       const result = await withLspClient(args.filePath, async (client) => {
-        return (await client.definition(args.filePath, args.line, args.character)) as
-          | Location
-          | Location[]
-          | LocationLink[]
-          | null
-      })
+        return (await client.definition(
+          args.filePath,
+          args.line,
+          args.character,
+        )) as Location | Location[] | LocationLink[] | null;
+      });
 
       if (!result) {
-        return "No definition found"
+        return 'No definition found';
       }
 
-      const locations = Array.isArray(result) ? result : [result]
+      const locations = Array.isArray(result) ? result : [result];
       if (locations.length === 0) {
-        return "No definition found"
+        return 'No definition found';
       }
 
-      return locations.map(formatLocation).join("\n")
+      return locations.map(formatLocation).join('\n');
     } catch (e) {
-      return formatError(e)
+      return formatError(e);
     }
   },
-})
+});
 
 export const lsp_find_references: ToolDefinition = tool({
-  description: "Find ALL usages/references of a symbol across the entire workspace.",
+  description:
+    'Find ALL usages/references of a symbol across the entire workspace.',
   args: {
-    filePath: tool.schema.string().describe("Absolute path to the file"),
-    line: tool.schema.number().min(1).describe("1-based line number"),
-    character: tool.schema.number().min(0).describe("0-based character offset"),
-    includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
+    filePath: tool.schema.string().describe('Absolute path to the file'),
+    line: tool.schema.number().min(1).describe('1-based line number'),
+    character: tool.schema.number().min(0).describe('0-based character offset'),
+    includeDeclaration: tool.schema
+      .boolean()
+      .optional()
+      .describe('Include the declaration itself'),
   },
   execute: async (args) => {
     try {
@@ -63,89 +72,107 @@ export const lsp_find_references: ToolDefinition = tool({
           args.filePath,
           args.line,
           args.character,
-          args.includeDeclaration ?? true
-        )) as Location[] | null
-      })
+          args.includeDeclaration ?? true,
+        )) as Location[] | null;
+      });
 
       if (!result || result.length === 0) {
-        return "No references found"
+        return 'No references found';
       }
 
-      const total = result.length
-      const truncated = total > DEFAULT_MAX_REFERENCES
-      const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
-      const lines = limited.map(formatLocation)
+      const total = result.length;
+      const truncated = total > DEFAULT_MAX_REFERENCES;
+      const limited = truncated
+        ? result.slice(0, DEFAULT_MAX_REFERENCES)
+        : result;
+      const lines = limited.map(formatLocation);
       if (truncated) {
-        lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
+        lines.unshift(
+          `Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`,
+        );
       }
-      return lines.join("\n")
+      return lines.join('\n');
     } catch (e) {
-      return formatError(e)
+      return formatError(e);
     }
   },
-})
+});
 
 export const lsp_diagnostics: ToolDefinition = tool({
-  description: "Get errors, warnings, hints from language server BEFORE running build.",
+  description:
+    'Get errors, warnings, hints from language server BEFORE running build.',
   args: {
-    filePath: tool.schema.string().describe("Absolute path to the file"),
+    filePath: tool.schema.string().describe('Absolute path to the file'),
     severity: tool.schema
-      .enum(["error", "warning", "information", "hint", "all"])
+      .enum(['error', 'warning', 'information', 'hint', 'all'])
       .optional()
-      .describe("Filter by severity level"),
+      .describe('Filter by severity level'),
   },
   execute: async (args) => {
     try {
       const result = await withLspClient(args.filePath, async (client) => {
-        return (await client.diagnostics(args.filePath)) as { items?: Diagnostic[] } | Diagnostic[] | null
-      })
+        return (await client.diagnostics(args.filePath)) as
+          | { items?: Diagnostic[] }
+          | Diagnostic[]
+          | null;
+      });
 
-      let diagnostics: Diagnostic[] = []
+      let diagnostics: Diagnostic[] = [];
       if (result) {
         if (Array.isArray(result)) {
-          diagnostics = result
+          diagnostics = result;
         } else if (result.items) {
-          diagnostics = result.items
+          diagnostics = result.items;
         }
       }
 
-      diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity)
+      diagnostics = filterDiagnosticsBySeverity(diagnostics, args.severity);
 
       if (diagnostics.length === 0) {
-        return "No diagnostics found"
+        return 'No diagnostics found';
       }
 
-      const total = diagnostics.length
-      const truncated = total > DEFAULT_MAX_DIAGNOSTICS
-      const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
-      const lines = limited.map(formatDiagnostic)
+      const total = diagnostics.length;
+      const truncated = total > DEFAULT_MAX_DIAGNOSTICS;
+      const limited = truncated
+        ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS)
+        : diagnostics;
+      const lines = limited.map(formatDiagnostic);
       if (truncated) {
-        lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
+        lines.unshift(
+          `Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`,
+        );
       }
-      return lines.join("\n")
+      return lines.join('\n');
     } catch (e) {
-      return formatError(e)
+      return formatError(e);
     }
   },
-})
+});
 
 export const lsp_rename: ToolDefinition = tool({
-  description: "Rename symbol across entire workspace. APPLIES changes to all files.",
+  description:
+    'Rename symbol across entire workspace. APPLIES changes to all files.',
   args: {
-    filePath: tool.schema.string().describe("Absolute path to the file"),
-    line: tool.schema.number().min(1).describe("1-based line number"),
-    character: tool.schema.number().min(0).describe("0-based character offset"),
-    newName: tool.schema.string().describe("New symbol name"),
+    filePath: tool.schema.string().describe('Absolute path to the file'),
+    line: tool.schema.number().min(1).describe('1-based line number'),
+    character: tool.schema.number().min(0).describe('0-based character offset'),
+    newName: tool.schema.string().describe('New symbol name'),
   },
   execute: async (args) => {
     try {
       const edit = await withLspClient(args.filePath, async (client) => {
-        return (await client.rename(args.filePath, args.line, args.character, args.newName)) as WorkspaceEdit | null
-      })
-      const result = applyWorkspaceEdit(edit)
-      return formatApplyResult(result)
+        return (await client.rename(
+          args.filePath,
+          args.line,
+          args.character,
+          args.newName,
+        )) as WorkspaceEdit | null;
+      });
+      const result = applyWorkspaceEdit(edit);
+      return formatApplyResult(result);
     } catch (e) {
-      return formatError(e)
+      return formatError(e);
     }
   },
-})
+});

+ 46 - 24
src/tools/lsp/types.ts

@@ -1,35 +1,57 @@
 import type {
-  Position, Range, Location, LocationLink,
-  Diagnostic, TextDocumentIdentifier, VersionedTextDocumentIdentifier,
-  TextEdit, TextDocumentEdit, CreateFile, RenameFile, DeleteFile,
-  WorkspaceEdit, SymbolInformation as SymbolInfo, DocumentSymbol
-} from 'vscode-languageserver-protocol'
+  CreateFile,
+  DeleteFile,
+  Diagnostic,
+  DocumentSymbol,
+  Location,
+  LocationLink,
+  Position,
+  Range,
+  RenameFile,
+  SymbolInformation as SymbolInfo,
+  TextDocumentEdit,
+  TextDocumentIdentifier,
+  TextEdit,
+  VersionedTextDocumentIdentifier,
+  WorkspaceEdit,
+} from 'vscode-languageserver-protocol';
 
 export interface LSPServerConfig {
-  id: string
-  command: string[]
-  extensions: string[]
-  disabled?: boolean
-  env?: Record<string, string>
-  initialization?: Record<string, unknown>
+  id: string;
+  command: string[];
+  extensions: string[];
+  disabled?: boolean;
+  env?: Record<string, string>;
+  initialization?: Record<string, unknown>;
 }
 
 export interface ResolvedServer {
-  id: string
-  command: string[]
-  extensions: string[]
-  env?: Record<string, string>
-  initialization?: Record<string, unknown>
+  id: string;
+  command: string[];
+  extensions: string[];
+  env?: Record<string, string>;
+  initialization?: Record<string, unknown>;
 }
 
 export type ServerLookupResult =
-  | { status: "found"; server: ResolvedServer }
-  | { status: "not_configured"; extension: string }
-  | { status: "not_installed"; server: ResolvedServer; installHint: string }
+  | { status: 'found'; server: ResolvedServer }
+  | { status: 'not_configured'; extension: string }
+  | { status: 'not_installed'; server: ResolvedServer; installHint: string };
 
 export type {
-  Position, Range, Location, LocationLink,
-  Diagnostic, TextDocumentIdentifier, VersionedTextDocumentIdentifier,
-  TextEdit, TextDocumentEdit, CreateFile, RenameFile, DeleteFile,
-  WorkspaceEdit, SymbolInfo, DocumentSymbol
-}
+  Position,
+  Range,
+  Location,
+  LocationLink,
+  Diagnostic,
+  TextDocumentIdentifier,
+  VersionedTextDocumentIdentifier,
+  TextEdit,
+  TextDocumentEdit,
+  CreateFile,
+  RenameFile,
+  DeleteFile,
+  WorkspaceEdit,
+  SymbolInfo,
+  DocumentSymbol,
+};

+ 129 - 119
src/tools/lsp/utils.test.ts

@@ -1,100 +1,102 @@
-import { expect, test, describe, mock, beforeEach } from "bun:test";
+import { beforeEach, describe, expect, mock, test } from 'bun:test';
 
 // Mock fs BEFORE importing modules
-mock.module("fs", () => ({
-  readFileSync: mock(() => ""),
+mock.module('fs', () => ({
+  readFileSync: mock(() => ''),
   writeFileSync: mock(),
   unlinkSync: mock(),
   existsSync: mock(() => true),
   statSync: mock(() => ({ isDirectory: () => false })),
 }));
 
-import { 
-  uriToPath, 
-  formatLocation, 
-  formatSeverity, 
-  formatDiagnostic, 
-  filterDiagnosticsBySeverity,
+import { readFileSync, unlinkSync, writeFileSync } from 'node:fs';
+import {
   applyWorkspaceEdit,
-  formatApplyResult
-} from "./utils";
-import { readFileSync, writeFileSync, unlinkSync } from "fs";
-
-describe("utils", () => {
+  filterDiagnosticsBySeverity,
+  formatApplyResult,
+  formatDiagnostic,
+  formatLocation,
+  formatSeverity,
+  uriToPath,
+} from './utils';
+
+describe('utils', () => {
   beforeEach(() => {
     (readFileSync as any).mockClear();
     (writeFileSync as any).mockClear();
     (unlinkSync as any).mockClear();
   });
 
-  describe("uriToPath", () => {
-    test("should convert file URI to path", () => {
-      const uri = "file:///home/user/project/file.ts";
+  describe('uriToPath', () => {
+    test('should convert file URI to path', () => {
+      const uri = 'file:///home/user/project/file.ts';
       const path = uriToPath(uri);
-      expect(path).toContain("home");
-      expect(path).toContain("file.ts");
+      expect(path).toContain('home');
+      expect(path).toContain('file.ts');
     });
   });
 
-  describe("formatLocation", () => {
-    test("should format Location object", () => {
+  describe('formatLocation', () => {
+    test('should format Location object', () => {
       const loc = {
-        uri: "file:///home/user/test.ts",
+        uri: 'file:///home/user/test.ts',
         range: {
           start: { line: 9, character: 5 },
-          end: { line: 9, character: 10 }
-        }
+          end: { line: 9, character: 10 },
+        },
       };
       const formatted = formatLocation(loc);
-      expect(formatted).toContain("test.ts:10:5");
+      expect(formatted).toContain('test.ts:10:5');
     });
   });
 
-  describe("formatSeverity", () => {
-    test("should map severity numbers to strings", () => {
-      expect(formatSeverity(1)).toBe("error");
-      expect(formatSeverity(2)).toBe("warning");
-      expect(formatSeverity(3)).toBe("information");
-      expect(formatSeverity(4)).toBe("hint");
-      expect(formatSeverity(undefined)).toBe("unknown");
+  describe('formatSeverity', () => {
+    test('should map severity numbers to strings', () => {
+      expect(formatSeverity(1)).toBe('error');
+      expect(formatSeverity(2)).toBe('warning');
+      expect(formatSeverity(3)).toBe('information');
+      expect(formatSeverity(4)).toBe('hint');
+      expect(formatSeverity(undefined)).toBe('unknown');
     });
   });
 
-  describe("formatDiagnostic", () => {
-    test("should format diagnostic correctly", () => {
+  describe('formatDiagnostic', () => {
+    test('should format diagnostic correctly', () => {
       const diag = {
         severity: 1,
         range: {
           start: { line: 0, character: 0 },
-          end: { line: 0, character: 5 }
+          end: { line: 0, character: 5 },
         },
-        message: "Unexpected token",
-        source: "eslint",
-        code: "no-unused-vars"
+        message: 'Unexpected token',
+        source: 'eslint',
+        code: 'no-unused-vars',
       };
       const formatted = formatDiagnostic(diag as any);
-      expect(formatted).toBe("error[eslint] (no-unused-vars) at 1:0: Unexpected token");
+      expect(formatted).toBe(
+        'error[eslint] (no-unused-vars) at 1:0: Unexpected token',
+      );
     });
   });
 
-  describe("filterDiagnosticsBySeverity", () => {
+  describe('filterDiagnosticsBySeverity', () => {
     const diags = [
-      { severity: 1, message: "e1" },
-      { severity: 2, message: "w1" },
+      { severity: 1, message: 'e1' },
+      { severity: 2, message: 'w1' },
     ] as any[];
 
-    test("should filter by error", () => {
-      const filtered = filterDiagnosticsBySeverity(diags, "error");
+    test('should filter by error', () => {
+      const filtered = filterDiagnosticsBySeverity(diags, 'error');
       expect(filtered).toHaveLength(1);
       expect(filtered[0].severity).toBe(1);
     });
   });
 
-  describe("applyWorkspaceEdit", () => {
-    test("should apply single file edit", () => {
-      const uri = "file:///test.ts";
+  describe('applyWorkspaceEdit', () => {
+    test('should apply single file edit', () => {
+      const uri = 'file:///test.ts';
       const filePath = uriToPath(uri);
-      (readFileSync as any).mockReturnValue("line1\nline2\nline3");
+      (readFileSync as any).mockReturnValue('line1\nline2\nline3');
 
       const edit = {
         changes: {
@@ -102,12 +104,12 @@ describe("utils", () => {
             {
               range: {
                 start: { line: 1, character: 0 },
-                end: { line: 1, character: 5 }
+                end: { line: 1, character: 5 },
               },
-              newText: "replaced"
-            }
-          ]
-        }
+              newText: 'replaced',
+            },
+          ],
+        },
       };
 
       const result = applyWorkspaceEdit(edit as any);
@@ -116,90 +118,98 @@ describe("utils", () => {
       expect(writeFileSync).toHaveBeenCalled();
     });
 
-    test("should handle overlapping edits by sorting them in reverse order", () => {
-        const uri = "file:///test.ts";
-        (readFileSync as any).mockReturnValue("abcde");
-  
-        const edit = {
-          changes: {
-            [uri]: [
-              {
-                range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
-                newText: "1"
+    test('should handle overlapping edits by sorting them in reverse order', () => {
+      const uri = 'file:///test.ts';
+      (readFileSync as any).mockReturnValue('abcde');
+
+      const edit = {
+        changes: {
+          [uri]: [
+            {
+              range: {
+                start: { line: 0, character: 0 },
+                end: { line: 0, character: 1 },
+              },
+              newText: '1',
+            },
+            {
+              range: {
+                start: { line: 0, character: 2 },
+                end: { line: 0, character: 3 },
               },
-              {
-                range: { start: { line: 0, character: 2 }, end: { line: 0, character: 3 } },
-                newText: "3"
-              }
-            ]
-          }
-        };
-  
-        const result = applyWorkspaceEdit(edit as any);
-        expect(result.success).toBe(true);
-        const writtenContent = (writeFileSync as any).mock.calls[0][1];
-        expect(writtenContent).toBe("1b3de");
+              newText: '3',
+            },
+          ],
+        },
+      };
+
+      const result = applyWorkspaceEdit(edit as any);
+      expect(result.success).toBe(true);
+      const writtenContent = (writeFileSync as any).mock.calls[0][1];
+      expect(writtenContent).toBe('1b3de');
     });
 
-    test("should handle create file operation", () => {
-        const edit = {
-            documentChanges: [
-                { kind: "create", uri: "file:///new.ts" }
-            ]
-        };
+    test('should handle create file operation', () => {
+      const edit = {
+        documentChanges: [{ kind: 'create', uri: 'file:///new.ts' }],
+      };
 
-        const result = applyWorkspaceEdit(edit as any);
-        expect(result.success).toBe(true);
-        expect(writeFileSync).toHaveBeenCalledWith(uriToPath("file:///new.ts"), "", "utf-8");
+      const result = applyWorkspaceEdit(edit as any);
+      expect(result.success).toBe(true);
+      expect(writeFileSync).toHaveBeenCalledWith(
+        uriToPath('file:///new.ts'),
+        '',
+        'utf-8',
+      );
     });
 
-    test("should handle rename file operation", () => {
-        const oldUri = "file:///old.ts";
-        const newUri = "file:///new.ts";
-        (readFileSync as any).mockReturnValue("some content");
-
-        const edit = {
-            documentChanges: [
-                { kind: "rename", oldUri, newUri }
-            ]
-        };
-
-        const result = applyWorkspaceEdit(edit as any);
-        expect(result.success).toBe(true);
-        expect(writeFileSync).toHaveBeenCalledWith(uriToPath(newUri), "some content", "utf-8");
-        expect(unlinkSync).toHaveBeenCalledWith(uriToPath(oldUri));
+    test('should handle rename file operation', () => {
+      const oldUri = 'file:///old.ts';
+      const newUri = 'file:///new.ts';
+      (readFileSync as any).mockReturnValue('some content');
+
+      const edit = {
+        documentChanges: [{ kind: 'rename', oldUri, newUri }],
+      };
+
+      const result = applyWorkspaceEdit(edit as any);
+      expect(result.success).toBe(true);
+      expect(writeFileSync).toHaveBeenCalledWith(
+        uriToPath(newUri),
+        'some content',
+        'utf-8',
+      );
+      expect(unlinkSync).toHaveBeenCalledWith(uriToPath(oldUri));
     });
 
-    test("should handle delete file operation", () => {
-        const uri = "file:///delete.ts";
-        const edit = {
-            documentChanges: [
-                { kind: "delete", uri }
-            ]
-        };
-
-        const result = applyWorkspaceEdit(edit as any);
-        expect(result.success).toBe(true);
-        expect(unlinkSync).toHaveBeenCalledWith(uriToPath(uri));
+    test('should handle delete file operation', () => {
+      const uri = 'file:///delete.ts';
+      const edit = {
+        documentChanges: [{ kind: 'delete', uri }],
+      };
+
+      const result = applyWorkspaceEdit(edit as any);
+      expect(result.success).toBe(true);
+      expect(unlinkSync).toHaveBeenCalledWith(uriToPath(uri));
     });
 
-    test("should return error if no edit provided", () => {
-        const result = applyWorkspaceEdit(null);
-        expect(result.success).toBe(false);
-        expect(result.errors).toContain("No edit provided");
+    test('should return error if no edit provided', () => {
+      const result = applyWorkspaceEdit(null);
+      expect(result.success).toBe(false);
+      expect(result.errors).toContain('No edit provided');
     });
   });
 
-  describe("formatApplyResult", () => {
-    test("should format successful result", () => {
+  describe('formatApplyResult', () => {
+    test('should format successful result', () => {
       const result = {
         success: true,
-        filesModified: ["/home/user/file1.ts"],
+        filesModified: ['/home/user/file1.ts'],
         totalEdits: 1,
-        errors: []
+        errors: [],
       };
       const formatted = formatApplyResult(result);
-      expect(formatted).toContain("Applied 1 edit(s)");
+      expect(formatted).toContain('Applied 1 edit(s)');
     });
   });
 });

+ 177 - 131
src/tools/lsp/utils.ts

@@ -1,132 +1,151 @@
 // LSP Utilities - Essential formatters and helpers
 
-import { extname, resolve, dirname, join } from "path"
-import { fileURLToPath } from "node:url"
-import { existsSync, readFileSync, writeFileSync, unlinkSync, statSync } from "fs"
-import { lspManager } from "./client"
-import type { LSPClient } from "./client"
-import { findServerForExtension } from "./config"
-import { SYMBOL_KIND_MAP, SEVERITY_MAP } from "./constants"
+import {
+  existsSync,
+  readFileSync,
+  statSync,
+  unlinkSync,
+  writeFileSync,
+} from 'node:fs';
+import { dirname, extname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { LSPClient } from './client';
+import { lspManager } from './client';
+import { findServerForExtension } from './config';
+import { SEVERITY_MAP, SYMBOL_KIND_MAP } from './constants';
 import type {
+  Diagnostic,
   Location,
   LocationLink,
-  Diagnostic,
-  WorkspaceEdit,
-  TextEdit,
   ServerLookupResult,
-} from "./types"
+  TextEdit,
+  WorkspaceEdit,
+} from './types';
 
 export function findWorkspaceRoot(filePath: string): string {
-  let dir = resolve(filePath)
+  let dir = resolve(filePath);
 
   try {
     if (!statSync(dir).isDirectory()) {
-      dir = dirname(dir)
+      dir = dirname(dir);
     }
   } catch {
-    dir = dirname(dir)
+    dir = dirname(dir);
   }
 
-  const markers = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"]
+  const markers = [
+    '.git',
+    'package.json',
+    'pyproject.toml',
+    'Cargo.toml',
+    'go.mod',
+  ];
 
-  let prevDir = ""
+  let prevDir = '';
   while (dir !== prevDir) {
     for (const marker of markers) {
       if (existsSync(join(dir, marker))) {
-        return dir
+        return dir;
       }
     }
-    prevDir = dir
-    dir = dirname(dir)
+    prevDir = dir;
+    dir = dirname(dir);
   }
 
-  return dirname(resolve(filePath))
+  return dirname(resolve(filePath));
 }
 
 export function uriToPath(uri: string): string {
-  return fileURLToPath(uri)
+  return fileURLToPath(uri);
 }
 
-export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
-  if (result.status === "not_installed") {
+export function formatServerLookupError(
+  result: Exclude<ServerLookupResult, { status: 'found' }>,
+): string {
+  if (result.status === 'not_installed') {
     return [
       `LSP server '${result.server.id}' is NOT INSTALLED.`,
       ``,
       `Command not found: ${result.server.command[0]}`,
       ``,
       `To install: ${result.installHint}`,
-    ].join("\n")
+    ].join('\n');
   }
 
-  return `No LSP server configured for extension: ${result.extension}`
+  return `No LSP server configured for extension: ${result.extension}`;
 }
 
-export async function withLspClient<T>(filePath: string, fn: (client: LSPClient) => Promise<T>): Promise<T> {
-  const absPath = resolve(filePath)
-  const ext = extname(absPath)
-  const result = findServerForExtension(ext)
+export async function withLspClient<T>(
+  filePath: string,
+  fn: (client: LSPClient) => Promise<T>,
+): Promise<T> {
+  const absPath = resolve(filePath);
+  const ext = extname(absPath);
+  const result = findServerForExtension(ext);
 
-  if (result.status !== "found") {
-    throw new Error(formatServerLookupError(result))
+  if (result.status !== 'found') {
+    throw new Error(formatServerLookupError(result));
   }
 
-  const server = result.server
-  const root = findWorkspaceRoot(absPath)
-  const client = await lspManager.getClient(root, server)
+  const server = result.server;
+  const root = findWorkspaceRoot(absPath);
+  const client = await lspManager.getClient(root, server);
 
   try {
-    return await fn(client)
+    return await fn(client);
   } catch (e) {
-    if (e instanceof Error && e.message.includes("timeout")) {
-      const isInitializing = lspManager.isServerInitializing(root, server.id)
+    if (e instanceof Error && e.message.includes('timeout')) {
+      const isInitializing = lspManager.isServerInitializing(root, server.id);
       if (isInitializing) {
-        throw new Error(`LSP server is still initializing. Please retry in a few seconds.`)
+        throw new Error(
+          `LSP server is still initializing. Please retry in a few seconds.`,
+        );
       }
     }
-    throw e
+    throw e;
   } finally {
-    lspManager.releaseClient(root, server.id)
+    lspManager.releaseClient(root, server.id);
   }
 }
 
 export function formatLocation(loc: Location | LocationLink): string {
-  if ("targetUri" in loc) {
-    const uri = uriToPath(loc.targetUri)
-    const line = loc.targetRange.start.line + 1
-    const char = loc.targetRange.start.character
-    return `${uri}:${line}:${char}`
+  if ('targetUri' in loc) {
+    const uri = uriToPath(loc.targetUri);
+    const line = loc.targetRange.start.line + 1;
+    const char = loc.targetRange.start.character;
+    return `${uri}:${line}:${char}`;
   }
 
-  const uri = uriToPath(loc.uri)
-  const line = loc.range.start.line + 1
-  const char = loc.range.start.character
-  return `${uri}:${line}:${char}`
+  const uri = uriToPath(loc.uri);
+  const line = loc.range.start.line + 1;
+  const char = loc.range.start.character;
+  return `${uri}:${line}:${char}`;
 }
 
 export function formatSymbolKind(kind: number): string {
-  return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})`
+  return SYMBOL_KIND_MAP[kind] || `Unknown(${kind})`;
 }
 
 export function formatSeverity(severity: number | undefined): string {
-  if (!severity) return "unknown"
-  return SEVERITY_MAP[severity] || `unknown(${severity})`
+  if (!severity) return 'unknown';
+  return SEVERITY_MAP[severity] || `unknown(${severity})`;
 }
 
 export function formatDiagnostic(diag: Diagnostic): string {
-  const severity = formatSeverity(diag.severity)
-  const line = diag.range.start.line + 1
-  const char = diag.range.start.character
-  const source = diag.source ? `[${diag.source}]` : ""
-  const code = diag.code ? ` (${diag.code})` : ""
-  return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`
+  const severity = formatSeverity(diag.severity);
+  const line = diag.range.start.line + 1;
+  const char = diag.range.start.character;
+  const source = diag.source ? `[${diag.source}]` : '';
+  const code = diag.code ? ` (${diag.code})` : '';
+  return `${severity}${source}${code} at ${line}:${char}: ${diag.message}`;
 }
 
 export function filterDiagnosticsBySeverity(
   diagnostics: Diagnostic[],
-  severityFilter?: "error" | "warning" | "information" | "hint" | "all"
+  severityFilter?: 'error' | 'warning' | 'information' | 'hint' | 'all',
 ): Diagnostic[] {
-  if (!severityFilter || severityFilter === "all") {
-    return diagnostics
+  if (!severityFilter || severityFilter === 'all') {
+    return diagnostics;
   }
 
   const severityMap: Record<string, number> = {
@@ -134,148 +153,175 @@ export function filterDiagnosticsBySeverity(
     warning: 2,
     information: 3,
     hint: 4,
-  }
+  };
 
-  const targetSeverity = severityMap[severityFilter]
-  return diagnostics.filter((d) => d.severity === targetSeverity)
+  const targetSeverity = severityMap[severityFilter];
+  return diagnostics.filter((d) => d.severity === targetSeverity);
 }
 
 // WorkspaceEdit application
 
-function applyTextEditsToFile(filePath: string, edits: TextEdit[]): { success: boolean; editCount: number; error?: string } {
+function applyTextEditsToFile(
+  filePath: string,
+  edits: TextEdit[],
+): { success: boolean; editCount: number; error?: string } {
   try {
-    const content = readFileSync(filePath, "utf-8")
-    const lines = content.split("\n")
+    const content = readFileSync(filePath, 'utf-8');
+    const lines = content.split('\n');
 
     const sortedEdits = [...edits].sort((a, b) => {
       if (b.range.start.line !== a.range.start.line) {
-        return b.range.start.line - a.range.start.line
+        return b.range.start.line - a.range.start.line;
       }
-      return b.range.start.character - a.range.start.character
-    })
+      return b.range.start.character - a.range.start.character;
+    });
 
     for (const edit of sortedEdits) {
-      const startLine = edit.range.start.line
-      const startChar = edit.range.start.character
-      const endLine = edit.range.end.line
-      const endChar = edit.range.end.character
+      const startLine = edit.range.start.line;
+      const startChar = edit.range.start.character;
+      const endLine = edit.range.end.line;
+      const endChar = edit.range.end.character;
 
       if (startLine === endLine) {
-        const line = lines[startLine] || ""
-        lines[startLine] = line.substring(0, startChar) + edit.newText + line.substring(endChar)
+        const line = lines[startLine] || '';
+        lines[startLine] =
+          line.substring(0, startChar) + edit.newText + line.substring(endChar);
       } else {
-        const firstLine = lines[startLine] || ""
-        const lastLine = lines[endLine] || ""
-        const newContent = firstLine.substring(0, startChar) + edit.newText + lastLine.substring(endChar)
-        lines.splice(startLine, endLine - startLine + 1, ...newContent.split("\n"))
+        const firstLine = lines[startLine] || '';
+        const lastLine = lines[endLine] || '';
+        const newContent =
+          firstLine.substring(0, startChar) +
+          edit.newText +
+          lastLine.substring(endChar);
+        lines.splice(
+          startLine,
+          endLine - startLine + 1,
+          ...newContent.split('\n'),
+        );
       }
     }
 
-    writeFileSync(filePath, lines.join("\n"), "utf-8")
-    return { success: true, editCount: edits.length }
+    writeFileSync(filePath, lines.join('\n'), 'utf-8');
+    return { success: true, editCount: edits.length };
   } catch (err) {
-    return { success: false, editCount: 0, error: err instanceof Error ? err.message : String(err) }
+    return {
+      success: false,
+      editCount: 0,
+      error: err instanceof Error ? err.message : String(err),
+    };
   }
 }
 
 export interface ApplyResult {
-  success: boolean
-  filesModified: string[]
-  totalEdits: number
-  errors: string[]
+  success: boolean;
+  filesModified: string[];
+  totalEdits: number;
+  errors: string[];
 }
 
 export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
   if (!edit) {
-    return { success: false, filesModified: [], totalEdits: 0, errors: ["No edit provided"] }
+    return {
+      success: false,
+      filesModified: [],
+      totalEdits: 0,
+      errors: ['No edit provided'],
+    };
   }
 
-  const result: ApplyResult = { success: true, filesModified: [], totalEdits: 0, errors: [] }
+  const result: ApplyResult = {
+    success: true,
+    filesModified: [],
+    totalEdits: 0,
+    errors: [],
+  };
 
   if (edit.changes) {
     for (const [uri, edits] of Object.entries(edit.changes)) {
-      const filePath = uriToPath(uri)
-      const applyResult = applyTextEditsToFile(filePath, edits)
+      const filePath = uriToPath(uri);
+      const applyResult = applyTextEditsToFile(filePath, edits);
 
       if (applyResult.success) {
-        result.filesModified.push(filePath)
-        result.totalEdits += applyResult.editCount
+        result.filesModified.push(filePath);
+        result.totalEdits += applyResult.editCount;
       } else {
-        result.success = false
-        result.errors.push(`${filePath}: ${applyResult.error}`)
+        result.success = false;
+        result.errors.push(`${filePath}: ${applyResult.error}`);
       }
     }
   }
 
   if (edit.documentChanges) {
     for (const change of edit.documentChanges) {
-      if ("kind" in change) {
-        if (change.kind === "create") {
+      if ('kind' in change) {
+        if (change.kind === 'create') {
           try {
-            const filePath = uriToPath(change.uri)
-            writeFileSync(filePath, "", "utf-8")
-            result.filesModified.push(filePath)
+            const filePath = uriToPath(change.uri);
+            writeFileSync(filePath, '', 'utf-8');
+            result.filesModified.push(filePath);
           } catch (err) {
-            result.success = false
-            result.errors.push(`Create ${change.uri}: ${err}`)
+            result.success = false;
+            result.errors.push(`Create ${change.uri}: ${err}`);
           }
-        } else if (change.kind === "rename") {
+        } else if (change.kind === 'rename') {
           try {
-            const oldPath = uriToPath(change.oldUri)
-            const newPath = uriToPath(change.newUri)
-            const content = readFileSync(oldPath, "utf-8")
-            writeFileSync(newPath, content, "utf-8")
-            unlinkSync(oldPath)
-            result.filesModified.push(newPath)
+            const oldPath = uriToPath(change.oldUri);
+            const newPath = uriToPath(change.newUri);
+            const content = readFileSync(oldPath, 'utf-8');
+            writeFileSync(newPath, content, 'utf-8');
+            unlinkSync(oldPath);
+            result.filesModified.push(newPath);
           } catch (err) {
-            result.success = false
-            result.errors.push(`Rename ${change.oldUri}: ${err}`)
+            result.success = false;
+            result.errors.push(`Rename ${change.oldUri}: ${err}`);
           }
-        } else if (change.kind === "delete") {
+        } else if (change.kind === 'delete') {
           try {
-            const filePath = uriToPath(change.uri)
-            unlinkSync(filePath)
-            result.filesModified.push(filePath)
+            const filePath = uriToPath(change.uri);
+            unlinkSync(filePath);
+            result.filesModified.push(filePath);
           } catch (err) {
-            result.success = false
-            result.errors.push(`Delete ${change.uri}: ${err}`)
+            result.success = false;
+            result.errors.push(`Delete ${change.uri}: ${err}`);
           }
         }
       } else {
-        const filePath = uriToPath(change.textDocument.uri)
-        const applyResult = applyTextEditsToFile(filePath, change.edits)
+        const filePath = uriToPath(change.textDocument.uri);
+        const applyResult = applyTextEditsToFile(filePath, change.edits);
 
         if (applyResult.success) {
-          result.filesModified.push(filePath)
-          result.totalEdits += applyResult.editCount
+          result.filesModified.push(filePath);
+          result.totalEdits += applyResult.editCount;
         } else {
-          result.success = false
-          result.errors.push(`${filePath}: ${applyResult.error}`)
+          result.success = false;
+          result.errors.push(`${filePath}: ${applyResult.error}`);
         }
       }
     }
   }
 
-  return result
+  return result;
 }
 
 export function formatApplyResult(result: ApplyResult): string {
-  const lines: string[] = []
+  const lines: string[] = [];
 
   if (result.success) {
-    lines.push(`Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`)
+    lines.push(
+      `Applied ${result.totalEdits} edit(s) to ${result.filesModified.length} file(s):`,
+    );
     for (const file of result.filesModified) {
-      lines.push(`  - ${file}`)
+      lines.push(`  - ${file}`);
     }
   } else {
-    lines.push("Failed to apply some changes:")
+    lines.push('Failed to apply some changes:');
     for (const err of result.errors) {
-      lines.push(`  Error: ${err}`)
+      lines.push(`  Error: ${err}`);
     }
     if (result.filesModified.length > 0) {
-      lines.push(`Successfully modified: ${result.filesModified.join(", ")}`)
+      lines.push(`Successfully modified: ${result.filesModified.join(', ')}`);
     }
   }
 
-  return lines.join("\n")
+  return lines.join('\n');
 }

+ 63 - 44
src/tools/quota/api.ts

@@ -1,51 +1,53 @@
-import * as path from "path";
-import * as os from "os";
-import * as fs from "fs";
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
 import type {
   Account,
+  AccountQuotaResult,
   AccountsConfig,
-  TokenResponse,
   LoadCodeAssistResponse,
-  QuotaResponse,
-  AccountQuotaResult,
   ModelQuota,
-} from "./types";
+  QuotaResponse,
+  TokenResponse,
+} from './types';
 
 // API endpoints
-const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
-const CLOUDCODE_BASE_URL = "https://cloudcode-pa.googleapis.com";
+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",
+  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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";
+const CLIENT_ID =
+  '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
+const CLIENT_SECRET = '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';
 
 // Config paths
-const isWindows = os.platform() === "win32";
+const isWindows = os.platform() === 'win32';
 const configBase = isWindows
-  ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
-  : path.join(os.homedir(), ".config", "opencode");
+  ? 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");
+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"),
+  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 JSON.parse(fs.readFileSync(p, 'utf-8')) as AccountsConfig;
     }
   }
   return null;
@@ -56,12 +58,12 @@ async function refreshToken(refreshToken: string): Promise<string> {
     client_id: CLIENT_ID,
     client_secret: CLIENT_SECRET,
     refresh_token: refreshToken,
-    grant_type: "refresh_token",
+    grant_type: 'refresh_token',
   });
 
   const res = await fetch(GOOGLE_TOKEN_URL, {
-    method: "POST",
-    headers: { "Content-Type": "application/x-www-form-urlencoded" },
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
     body: params.toString(),
   });
 
@@ -70,13 +72,15 @@ async function refreshToken(refreshToken: string): Promise<string> {
   return data.access_token;
 }
 
-async function loadCodeAssist(accessToken: string): Promise<LoadCodeAssistResponse> {
+async function loadCodeAssist(
+  accessToken: string,
+): Promise<LoadCodeAssistResponse> {
   const res = await fetch(`${CLOUDCODE_BASE_URL}/v1internal:loadCodeAssist`, {
-    method: "POST",
+    method: 'POST',
     headers: {
       Authorization: `Bearer ${accessToken}`,
-      "Content-Type": "application/json",
-      "User-Agent": "antigravity",
+      'Content-Type': 'application/json',
+      'User-Agent': 'antigravity',
     },
     body: JSON.stringify({ metadata: CLOUDCODE_METADATA }),
   });
@@ -86,25 +90,31 @@ async function loadCodeAssist(accessToken: string): Promise<LoadCodeAssistRespon
 }
 
 function extractProjectId(project: unknown): string | undefined {
-  if (typeof project === "string" && project) return project;
-  if (project && typeof project === "object" && "id" in project) {
+  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> {
+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",
+  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),
     },
-    body: JSON.stringify(payload),
-  });
+  );
 
   if (!res.ok) throw new Error(`fetchModels failed (${res.status})`);
   return (await res.json()) as QuotaResponse;
@@ -119,9 +129,16 @@ function formatDuration(ms: number): string {
 }
 
 // Filter out internal/test models
-const EXCLUDED_PATTERNS = ["chat_", "rev19", "gemini 2.5", "gemini 3 pro image"];
+const EXCLUDED_PATTERNS = [
+  'chat_',
+  'rev19',
+  'gemini 2.5',
+  'gemini 3 pro image',
+];
 
-export async function fetchAccountQuota(account: Account): Promise<AccountQuotaResult> {
+export async function fetchAccountQuota(
+  account: Account,
+): Promise<AccountQuotaResult> {
   try {
     const accessToken = await refreshToken(account.refreshToken);
     let projectId = account.projectId || account.managedProjectId;
@@ -151,7 +168,7 @@ export async function fetchAccountQuota(account: Account): Promise<AccountQuotaR
       let resetMs = DEFAULT_RESET_MS;
       if (qi.resetTime) {
         const parsed = new Date(qi.resetTime).getTime();
-        if (!isNaN(parsed)) resetMs = Math.max(0, parsed - now);
+        if (!Number.isNaN(parsed)) resetMs = Math.max(0, parsed - now);
       }
 
       models.push({
@@ -174,7 +191,9 @@ export async function fetchAccountQuota(account: Account): Promise<AccountQuotaR
   }
 }
 
-export async function fetchAllQuotas(accounts: Account[]): Promise<AccountQuotaResult[]> {
+export async function fetchAllQuotas(
+  accounts: Account[],
+): Promise<AccountQuotaResult[]> {
   const results: AccountQuotaResult[] = [];
   for (let i = 0; i < accounts.length; i++) {
     if (i > 0) await new Promise((r) => setTimeout(r, ACCOUNT_FETCH_DELAY_MS));

+ 14 - 14
src/tools/quota/command.ts

@@ -1,15 +1,15 @@
-import * as path from "path";
-import * as os from "os";
-import * as fs from "fs";
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
 
 // Define base configuration directory based on OS
-const isWindows = os.platform() === "win32";
+const isWindows = os.platform() === 'win32';
 const configBase = isWindows
-  ? path.join(os.homedir(), "AppData", "Roaming", "opencode")
-  : path.join(os.homedir(), ".config", "opencode");
+  ? 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 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
@@ -36,14 +36,14 @@ try {
     fs.mkdirSync(commandDir, { recursive: true });
   }
   if (!fs.existsSync(commandFile)) {
-    fs.writeFileSync(commandFile, commandContent, "utf-8");
+    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");
+    const currentContent = fs.readFileSync(commandFile, 'utf-8');
+    if (currentContent.includes('model: opencode/grok-code')) {
+      fs.writeFileSync(commandFile, commandContent, 'utf-8');
     }
   }
 } catch (error) {
-  console.error("Failed to create command file/directory:", error);
+  console.error('Failed to create command file/directory:', error);
   // Continue execution, as this might not be fatal for the plugin's core function
-}
+}

+ 37 - 28
src/tools/quota/index.ts

@@ -1,17 +1,17 @@
-import { tool } from "@opencode-ai/plugin";
-import { loadAccountsConfig, fetchAllQuotas, CONFIG_PATHS } from "./api";
-import type { ModelQuota } from "./types";
+import { tool } from '@opencode-ai/plugin';
+import { CONFIG_PATHS, fetchAllQuotas, loadAccountsConfig } 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
@@ -19,13 +19,14 @@ import type { ModelQuota } from "./types";
  * ```
  */
 export const antigravity_quota = tool({
-  description: "Check Antigravity API quota for all accounts (compact view with progress bars)",
+  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")}`;
+        return `No accounts found. Checked:\n${CONFIG_PATHS.map((p) => `  - ${p}`).join('\n')}`;
       }
 
       // Create accounts with default emails if missing (don't mutate original)
@@ -45,7 +46,7 @@ export const antigravity_quota = tool({
         }
 
         const email = shortEmail(result.email);
-        
+
         if (result.models.length === 0) {
           blocks.push(`${email}\n  (no models)`);
           continue;
@@ -54,7 +55,7 @@ export const antigravity_quota = tool({
         // 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);
@@ -64,16 +65,17 @@ export const antigravity_quota = tool({
           }
         }
 
-        blocks.push(lines.join("\n"));
+        blocks.push(lines.join('\n'));
       }
 
-      let output = "# Quota\n```\n";
+      let output = '# Quota\n```\n';
       if (errors.length > 0) {
-        output += `Errors: ${errors.join(", ")}\n\n`;
+        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. -->";
+      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;
     } catch (err) {
@@ -83,27 +85,34 @@ export const antigravity_quota = tool({
 });
 
 // Group models into 3 families: Claude (opus/sonnet/gpt), G-Flash, G-Pro
-function groupByFamily(models: ModelQuota[]): Record<string, ModelQuota | null> {
+function groupByFamily(
+  models: ModelQuota[],
+): Record<string, ModelQuota | null> {
   const families: Record<string, ModelQuota | null> = {
-    "Claude": null,
-    "G-Flash": null,
-    "G-Pro": 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;
+    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;
+    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;
+    else if (lower.includes('gemini') || lower.includes('pro')) {
+      if (!families['G-Pro']) families['G-Pro'] = m;
     }
   }
 
@@ -115,10 +124,10 @@ function progressBar(percent: number): string {
   const width = 10;
   const filled = Math.round((percent / 100) * width);
   const empty = width - filled;
-  return `[${"█".repeat(filled)}${"░".repeat(empty)}]`;
+  return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
 }
 
 // Shorten email to username part
 function shortEmail(email: string): string {
-  return email.split("@")[0] ?? email;
+  return email.split('@')[0] ?? email;
 }

+ 172 - 168
src/tools/skill/builtin.test.ts

@@ -1,209 +1,213 @@
-import { describe, expect, test } from "bun:test"
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../../config/schema';
 import {
+  canAgentUseSkill,
+  DEFAULT_AGENT_SKILLS,
   getBuiltinSkills,
   getSkillByName,
   getSkillsForAgent,
-  canAgentUseSkill,
-  DEFAULT_AGENT_SKILLS,
-} from "./builtin"
-import type { PluginConfig } from "../../config/schema"
-
-describe("getBuiltinSkills", () => {
-  test("returns all builtin skills", () => {
-    const skills = getBuiltinSkills()
-    expect(skills.length).toBeGreaterThan(0)
-    
-    const names = skills.map(s => s.name)
-    expect(names).toContain("yagni-enforcement")
-    expect(names).toContain("playwright")
-  })
-})
-
-describe("getSkillByName", () => {
-  test("returns skill by exact name", () => {
-    const skill = getSkillByName("yagni-enforcement")
-    expect(skill).toBeDefined()
-    expect(skill?.name).toBe("yagni-enforcement")
-  })
-
-  test("returns undefined for unknown skill", () => {
-    const skill = getSkillByName("nonexistent-skill")
-    expect(skill).toBeUndefined()
-  })
-
-  test("returns playwright skill with mcpConfig", () => {
-    const skill = getSkillByName("playwright")
-    expect(skill).toBeDefined()
-    expect(skill?.mcpConfig).toBeDefined()
-    expect(skill?.mcpConfig?.playwright).toBeDefined()
-  })
-})
-
-describe("DEFAULT_AGENT_SKILLS", () => {
-  test("orchestrator has wildcard access", () => {
-    expect(DEFAULT_AGENT_SKILLS.orchestrator).toContain("*")
-  })
-
-  test("designer has playwright skill", () => {
-    expect(DEFAULT_AGENT_SKILLS.designer).toContain("playwright")
-  })
-
-  test("oracle has no skills by default", () => {
-    expect(DEFAULT_AGENT_SKILLS.oracle).toEqual([])
-  })
-
-  test("librarian has no skills by default", () => {
-    expect(DEFAULT_AGENT_SKILLS.librarian).toEqual([])
-  })
-
-  test("explorer has no skills by default", () => {
-    expect(DEFAULT_AGENT_SKILLS.explorer).toEqual([])
-  })
-
-  test("fixer has no skills by default", () => {
-    expect(DEFAULT_AGENT_SKILLS.fixer).toEqual([])
-  })
-})
-
-describe("getSkillsForAgent", () => {
-  test("returns all skills for orchestrator (wildcard)", () => {
-    const skills = getSkillsForAgent("orchestrator")
-    const allSkills = getBuiltinSkills()
-    expect(skills.length).toBe(allSkills.length)
-  })
-
-  test("returns playwright for designer", () => {
-    const skills = getSkillsForAgent("designer")
-    const names = skills.map(s => s.name)
-    expect(names).toContain("playwright")
-  })
-
-  test("returns empty for oracle", () => {
-    const skills = getSkillsForAgent("oracle")
-    expect(skills).toEqual([])
-  })
-
-  test("respects config override for agent skills", () => {
+} from './builtin';
+
+describe('getBuiltinSkills', () => {
+  test('returns all builtin skills', () => {
+    const skills = getBuiltinSkills();
+    expect(skills.length).toBeGreaterThan(0);
+
+    const names = skills.map((s) => s.name);
+    expect(names).toContain('yagni-enforcement');
+    expect(names).toContain('playwright');
+  });
+});
+
+describe('getSkillByName', () => {
+  test('returns skill by exact name', () => {
+    const skill = getSkillByName('yagni-enforcement');
+    expect(skill).toBeDefined();
+    expect(skill?.name).toBe('yagni-enforcement');
+  });
+
+  test('returns undefined for unknown skill', () => {
+    const skill = getSkillByName('nonexistent-skill');
+    expect(skill).toBeUndefined();
+  });
+
+  test('returns playwright skill with mcpConfig', () => {
+    const skill = getSkillByName('playwright');
+    expect(skill).toBeDefined();
+    expect(skill?.mcpConfig).toBeDefined();
+    expect(skill?.mcpConfig?.playwright).toBeDefined();
+  });
+});
+
+describe('DEFAULT_AGENT_SKILLS', () => {
+  test('orchestrator has wildcard access', () => {
+    expect(DEFAULT_AGENT_SKILLS.orchestrator).toContain('*');
+  });
+
+  test('designer has playwright skill', () => {
+    expect(DEFAULT_AGENT_SKILLS.designer).toContain('playwright');
+  });
+
+  test('oracle has no skills by default', () => {
+    expect(DEFAULT_AGENT_SKILLS.oracle).toEqual([]);
+  });
+
+  test('librarian has no skills by default', () => {
+    expect(DEFAULT_AGENT_SKILLS.librarian).toEqual([]);
+  });
+
+  test('explorer has no skills by default', () => {
+    expect(DEFAULT_AGENT_SKILLS.explorer).toEqual([]);
+  });
+
+  test('fixer has no skills by default', () => {
+    expect(DEFAULT_AGENT_SKILLS.fixer).toEqual([]);
+  });
+});
+
+describe('getSkillsForAgent', () => {
+  test('returns all skills for orchestrator (wildcard)', () => {
+    const skills = getSkillsForAgent('orchestrator');
+    const allSkills = getBuiltinSkills();
+    expect(skills.length).toBe(allSkills.length);
+  });
+
+  test('returns playwright for designer', () => {
+    const skills = getSkillsForAgent('designer');
+    const names = skills.map((s) => s.name);
+    expect(names).toContain('playwright');
+  });
+
+  test('returns empty for oracle', () => {
+    const skills = getSkillsForAgent('oracle');
+    expect(skills).toEqual([]);
+  });
+
+  test('respects config override for agent skills', () => {
     const config: PluginConfig = {
       agents: {
-        oracle: { skills: ["yagni-enforcement"] },
+        oracle: { skills: ['yagni-enforcement'] },
       },
-    }
-    const skills = getSkillsForAgent("oracle", config)
-    expect(skills.length).toBe(1)
-    expect(skills[0].name).toBe("yagni-enforcement")
-  })
+    };
+    const skills = getSkillsForAgent('oracle', config);
+    expect(skills.length).toBe(1);
+    expect(skills[0].name).toBe('yagni-enforcement');
+  });
 
-  test("config wildcard overrides default", () => {
+  test('config wildcard overrides default', () => {
     const config: PluginConfig = {
       agents: {
-        explorer: { skills: ["*"] },
+        explorer: { skills: ['*'] },
       },
-    }
-    const skills = getSkillsForAgent("explorer", config)
-    const allSkills = getBuiltinSkills()
-    expect(skills.length).toBe(allSkills.length)
-  })
+    };
+    const skills = getSkillsForAgent('explorer', config);
+    const allSkills = getBuiltinSkills();
+    expect(skills.length).toBe(allSkills.length);
+  });
 
-  test("config empty array removes default skills", () => {
+  test('config empty array removes default skills', () => {
     const config: PluginConfig = {
       agents: {
         designer: { skills: [] },
       },
-    }
-    const skills = getSkillsForAgent("designer", config)
-    expect(skills).toEqual([])
-  })
+    };
+    const skills = getSkillsForAgent('designer', config);
+    expect(skills).toEqual([]);
+  });
 
   test("backward compat: 'explore' alias config applies to explorer", () => {
     const config: PluginConfig = {
       agents: {
-        explore: { skills: ["playwright"] },
+        explore: { skills: ['playwright'] },
       },
-    }
-    const skills = getSkillsForAgent("explorer", config)
-    expect(skills.length).toBe(1)
-    expect(skills[0].name).toBe("playwright")
-  })
+    };
+    const skills = getSkillsForAgent('explorer', config);
+    expect(skills.length).toBe(1);
+    expect(skills[0].name).toBe('playwright');
+  });
 
   test("backward compat: 'frontend-ui-ux-engineer' alias applies to designer", () => {
     const config: PluginConfig = {
       agents: {
-        "frontend-ui-ux-engineer": { skills: ["yagni-enforcement"] },
+        'frontend-ui-ux-engineer': { skills: ['yagni-enforcement'] },
       },
-    }
-    const skills = getSkillsForAgent("designer", config)
-    expect(skills.length).toBe(1)
-    expect(skills[0].name).toBe("yagni-enforcement")
-  })
-
-  test("returns empty for unknown agent without config", () => {
-    const skills = getSkillsForAgent("unknown-agent")
-    expect(skills).toEqual([])
-  })
-})
-
-describe("canAgentUseSkill", () => {
-  test("orchestrator can use any skill (wildcard)", () => {
-    expect(canAgentUseSkill("orchestrator", "yagni-enforcement")).toBe(true)
-    expect(canAgentUseSkill("orchestrator", "playwright")).toBe(true)
-    expect(canAgentUseSkill("orchestrator", "any-skill")).toBe(true)
-  })
-
-  test("designer can use playwright", () => {
-    expect(canAgentUseSkill("designer", "playwright")).toBe(true)
-  })
-
-  test("designer cannot use yagni-enforcement by default", () => {
-    expect(canAgentUseSkill("designer", "yagni-enforcement")).toBe(false)
-  })
-
-  test("oracle cannot use any skill by default", () => {
-    expect(canAgentUseSkill("oracle", "yagni-enforcement")).toBe(false)
-    expect(canAgentUseSkill("oracle", "playwright")).toBe(false)
-  })
-
-  test("respects config override", () => {
+    };
+    const skills = getSkillsForAgent('designer', config);
+    expect(skills.length).toBe(1);
+    expect(skills[0].name).toBe('yagni-enforcement');
+  });
+
+  test('returns empty for unknown agent without config', () => {
+    const skills = getSkillsForAgent('unknown-agent');
+    expect(skills).toEqual([]);
+  });
+});
+
+describe('canAgentUseSkill', () => {
+  test('orchestrator can use any skill (wildcard)', () => {
+    expect(canAgentUseSkill('orchestrator', 'yagni-enforcement')).toBe(true);
+    expect(canAgentUseSkill('orchestrator', 'playwright')).toBe(true);
+    expect(canAgentUseSkill('orchestrator', 'any-skill')).toBe(true);
+  });
+
+  test('designer can use playwright', () => {
+    expect(canAgentUseSkill('designer', 'playwright')).toBe(true);
+  });
+
+  test('designer cannot use yagni-enforcement by default', () => {
+    expect(canAgentUseSkill('designer', 'yagni-enforcement')).toBe(false);
+  });
+
+  test('oracle cannot use any skill by default', () => {
+    expect(canAgentUseSkill('oracle', 'yagni-enforcement')).toBe(false);
+    expect(canAgentUseSkill('oracle', 'playwright')).toBe(false);
+  });
+
+  test('respects config override', () => {
     const config: PluginConfig = {
       agents: {
-        oracle: { skills: ["yagni-enforcement"] },
+        oracle: { skills: ['yagni-enforcement'] },
       },
-    }
-    expect(canAgentUseSkill("oracle", "yagni-enforcement", config)).toBe(true)
-    expect(canAgentUseSkill("oracle", "playwright", config)).toBe(false)
-  })
+    };
+    expect(canAgentUseSkill('oracle', 'yagni-enforcement', config)).toBe(true);
+    expect(canAgentUseSkill('oracle', 'playwright', config)).toBe(false);
+  });
 
-  test("config wildcard grants all permissions", () => {
+  test('config wildcard grants all permissions', () => {
     const config: PluginConfig = {
       agents: {
-        librarian: { skills: ["*"] },
+        librarian: { skills: ['*'] },
       },
-    }
-    expect(canAgentUseSkill("librarian", "yagni-enforcement", config)).toBe(true)
-    expect(canAgentUseSkill("librarian", "playwright", config)).toBe(true)
-    expect(canAgentUseSkill("librarian", "any-other-skill", config)).toBe(true)
-  })
-
-  test("config empty array denies all", () => {
+    };
+    expect(canAgentUseSkill('librarian', 'yagni-enforcement', config)).toBe(
+      true,
+    );
+    expect(canAgentUseSkill('librarian', 'playwright', config)).toBe(true);
+    expect(canAgentUseSkill('librarian', 'any-other-skill', config)).toBe(true);
+  });
+
+  test('config empty array denies all', () => {
     const config: PluginConfig = {
       agents: {
         designer: { skills: [] },
       },
-    }
-    expect(canAgentUseSkill("designer", "playwright", config)).toBe(false)
-  })
+    };
+    expect(canAgentUseSkill('designer', 'playwright', config)).toBe(false);
+  });
 
-  test("backward compat: alias config affects agent permissions", () => {
+  test('backward compat: alias config affects agent permissions', () => {
     const config: PluginConfig = {
       agents: {
-        explore: { skills: ["playwright"] },
+        explore: { skills: ['playwright'] },
       },
-    }
-    expect(canAgentUseSkill("explorer", "playwright", config)).toBe(true)
-    expect(canAgentUseSkill("explorer", "yagni-enforcement", config)).toBe(false)
-  })
-
-  test("unknown agent returns false without config", () => {
-    expect(canAgentUseSkill("unknown-agent", "playwright")).toBe(false)
-  })
-})
+    };
+    expect(canAgentUseSkill('explorer', 'playwright', config)).toBe(true);
+    expect(canAgentUseSkill('explorer', 'yagni-enforcement', config)).toBe(
+      false,
+    );
+  });
+
+  test('unknown agent returns false without config', () => {
+    expect(canAgentUseSkill('unknown-agent', 'playwright')).toBe(false);
+  });
+});

+ 27 - 23
src/tools/skill/builtin.ts

@@ -1,16 +1,16 @@
-import type { SkillDefinition } from "./types";
-import type { PluginConfig, AgentName } from "../../config/schema";
+import type { AgentName, PluginConfig } from '../../config/schema';
+import type { SkillDefinition } from './types';
 
 /** Map old agent names to new names for backward compatibility */
 const AGENT_ALIASES: Record<string, string> = {
-  "explore": "explorer",
-  "frontend-ui-ux-engineer": "designer",
+  explore: 'explorer',
+  'frontend-ui-ux-engineer': 'designer',
 };
 
 /** Default skills per agent - "*" means all skills */
 export const DEFAULT_AGENT_SKILLS: Record<AgentName, string[]> = {
-  orchestrator: ["*"],
-  designer: ["playwright"],
+  orchestrator: ['*'],
+  designer: ['playwright'],
   oracle: [],
   librarian: [],
   explorer: [],
@@ -133,21 +133,21 @@ This skill provides browser automation capabilities via the Playwright MCP serve
 5. Return results with visual proof`;
 
 const yagniEnforcementSkill: SkillDefinition = {
-  name: "yagni-enforcement",
+  name: 'yagni-enforcement',
   description:
-    "Code complexity analysis and YAGNI enforcement. Use after major refactors or before finalizing PRs to simplify code.",
+    'Code complexity analysis and YAGNI enforcement. Use after major refactors or before finalizing PRs to simplify code.',
   template: YAGNI_TEMPLATE,
 };
 
 const playwrightSkill: SkillDefinition = {
-  name: "playwright",
+  name: 'playwright',
   description:
-    "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
+    'MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.',
   template: PLAYWRIGHT_TEMPLATE,
   mcpConfig: {
     playwright: {
-      command: "npx",
-      args: ["@playwright/mcp@latest"],
+      command: 'npx',
+      args: ['@playwright/mcp@latest'],
     },
   },
 };
@@ -172,16 +172,16 @@ export function getSkillByName(name: string): SkillDefinition | undefined {
  */
 export function getSkillsForAgent(
   agentName: string,
-  config?: PluginConfig
+  config?: PluginConfig,
 ): SkillDefinition[] {
   const allSkills = getBuiltinSkills();
   const agentSkills = getAgentSkillList(agentName, config);
-  
+
   // "*" means all skills
-  if (agentSkills.includes("*")) {
+  if (agentSkills.includes('*')) {
     return allSkills;
   }
-  
+
   return allSkills.filter((skill) => agentSkills.includes(skill.name));
 }
 
@@ -191,15 +191,15 @@ export function getSkillsForAgent(
 export function canAgentUseSkill(
   agentName: string,
   skillName: string,
-  config?: PluginConfig
+  config?: PluginConfig,
 ): boolean {
   const agentSkills = getAgentSkillList(agentName, config);
-  
+
   // "*" means all skills
-  if (agentSkills.includes("*")) {
+  if (agentSkills.includes('*')) {
     return true;
   }
-  
+
   return agentSkills.includes(skillName);
 }
 
@@ -209,12 +209,16 @@ export function canAgentUseSkill(
  */
 function getAgentSkillList(agentName: string, config?: PluginConfig): string[] {
   // Check if config has override for this agent (new name first, then alias)
-  const agentConfig = config?.agents?.[agentName] ??
-    config?.agents?.[Object.keys(AGENT_ALIASES).find(k => AGENT_ALIASES[k] === agentName) ?? ""];
+  const agentConfig =
+    config?.agents?.[agentName] ??
+    config?.agents?.[
+      Object.keys(AGENT_ALIASES).find((k) => AGENT_ALIASES[k] === agentName) ??
+        ''
+    ];
   if (agentConfig?.skills !== undefined) {
     return agentConfig.skills;
   }
-  
+
   // Fall back to defaults
   const defaultSkills = DEFAULT_AGENT_SKILLS[agentName as AgentName];
   return defaultSkills ?? [];

+ 5 - 5
src/tools/skill/index.ts

@@ -1,8 +1,8 @@
-export { createSkillTools } from "./tools";
-export { SkillMcpManager } from "./mcp-manager";
+export { SkillMcpManager } from './mcp-manager';
+export { createSkillTools } from './tools';
 export type {
-  SkillDefinition,
+  McpServerConfig,
   SkillArgs,
+  SkillDefinition,
   SkillMcpArgs,
-  McpServerConfig,
-} from "./types";
+} from './types';

+ 11 - 11
src/tools/skill/mcp-manager.test.ts

@@ -1,15 +1,15 @@
-import { describe, expect, test } from "bun:test"
-import { SkillMcpManager } from "./mcp-manager"
+import { describe, expect, test } from 'bun:test';
+import { SkillMcpManager } from './mcp-manager';
 
-describe("SkillMcpManager", () => {
-  test("returns singleton instance", () => {
-    const instance1 = SkillMcpManager.getInstance()
-    const instance2 = SkillMcpManager.getInstance()
-    
-    expect(instance1).toBe(instance2)
-    expect(instance1).toBeDefined()
-  })
-})
+describe('SkillMcpManager', () => {
+  test('returns singleton instance', () => {
+    const instance1 = SkillMcpManager.getInstance();
+    const instance2 = SkillMcpManager.getInstance();
+
+    expect(instance1).toBe(instance2);
+    expect(instance1).toBeDefined();
+  });
+});
 
 // Note: Connection and tool-calling tests require actual MCP servers
 // and are better suited for integration tests, not unit tests.

+ 49 - 36
src/tools/skill/mcp-manager.ts

@@ -1,7 +1,11 @@
-import { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
-import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
-import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js";
+import { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
+import type {
+  Prompt,
+  Resource,
+  Tool,
+} from '@modelcontextprotocol/sdk/types.js';
 import type {
   ConnectionType,
   ManagedClient,
@@ -9,10 +13,10 @@ import type {
   ManagedStdioClient,
   McpServerConfig,
   SkillMcpClientInfo,
-} from "./types";
+} from './types';
 
 function getConnectionType(config: McpServerConfig): ConnectionType {
-  return "url" in config ? "http" : "stdio";
+  return 'url' in config ? 'http' : 'stdio';
 }
 
 export class SkillMcpManager {
@@ -51,12 +55,12 @@ export class SkillMcpManager {
       }
     };
 
-    process.on("exit", cleanup);
-    process.on("SIGINT", () => {
+    process.on('exit', cleanup);
+    process.on('SIGINT', () => {
       cleanup();
       process.exit(0);
     });
-    process.on("SIGTERM", () => {
+    process.on('SIGTERM', () => {
       cleanup();
       process.exit(0);
     });
@@ -68,11 +72,11 @@ export class SkillMcpManager {
 
   private async createClient(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Client> {
     const connectionType = getConnectionType(config);
 
-    if (connectionType === "http") {
+    if (connectionType === 'http') {
       return this.createHttpClient(info, config);
     }
 
@@ -81,11 +85,11 @@ export class SkillMcpManager {
 
   private async createHttpClient(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Client> {
-    if (!("url" in config)) {
+    if (!('url' in config)) {
       throw new Error(
-        `MCP server "${info.serverName}" missing url for HTTP connection.`
+        `MCP server "${info.serverName}" missing url for HTTP connection.`,
       );
     }
 
@@ -96,12 +100,16 @@ export class SkillMcpManager {
     }
 
     const transport = new StreamableHTTPClientTransport(url, {
-      requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
+      requestInit:
+        Object.keys(requestInit).length > 0 ? requestInit : undefined,
     });
 
     const client = new Client(
-      { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
-      { capabilities: {} }
+      {
+        name: `skill-mcp-${info.skillName}-${info.serverName}`,
+        version: '1.0.0',
+      },
+      { capabilities: {} },
     );
 
     try {
@@ -112,9 +120,10 @@ export class SkillMcpManager {
       } catch {
         // ignore transport close errors
       }
-      const errorMessage = error instanceof Error ? error.message : String(error);
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
       throw new Error(
-        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`
+        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`,
       );
     }
 
@@ -123,7 +132,7 @@ export class SkillMcpManager {
       transport,
       skillName: info.skillName,
       lastUsedAt: Date.now(),
-      connectionType: "http",
+      connectionType: 'http',
     };
 
     this.clients.set(this.getClientKey(info), managedClient);
@@ -133,11 +142,11 @@ export class SkillMcpManager {
 
   private async createStdioClient(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Client> {
-    if (!("command" in config)) {
+    if (!('command' in config)) {
       throw new Error(
-        `MCP server "${info.serverName}" missing command for stdio connection.`
+        `MCP server "${info.serverName}" missing command for stdio connection.`,
       );
     }
 
@@ -145,12 +154,15 @@ export class SkillMcpManager {
       command: config.command,
       args: config.args || [],
       env: config.env,
-      stderr: "ignore",
+      stderr: 'ignore',
     });
 
     const client = new Client(
-      { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
-      { capabilities: {} }
+      {
+        name: `skill-mcp-${info.skillName}-${info.serverName}`,
+        version: '1.0.0',
+      },
+      { capabilities: {} },
     );
 
     try {
@@ -161,9 +173,10 @@ export class SkillMcpManager {
       } catch {
         // ignore transport close errors
       }
-      const errorMessage = error instanceof Error ? error.message : String(error);
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
       throw new Error(
-        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`
+        `Failed to connect to MCP server "${info.serverName}". ${errorMessage}`,
       );
     }
 
@@ -172,7 +185,7 @@ export class SkillMcpManager {
       transport,
       skillName: info.skillName,
       lastUsedAt: Date.now(),
-      connectionType: "stdio",
+      connectionType: 'stdio',
     };
 
     this.clients.set(this.getClientKey(info), managedClient);
@@ -182,7 +195,7 @@ export class SkillMcpManager {
 
   private async getOrCreateClient(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Client> {
     const key = this.getClientKey(info);
     const existing = this.clients.get(key);
@@ -208,7 +221,7 @@ export class SkillMcpManager {
 
   async listTools(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Tool[]> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.listTools();
@@ -217,7 +230,7 @@ export class SkillMcpManager {
 
   async listResources(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Resource[]> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.listResources();
@@ -226,7 +239,7 @@ export class SkillMcpManager {
 
   async listPrompts(
     info: SkillMcpClientInfo,
-    config: McpServerConfig
+    config: McpServerConfig,
   ): Promise<Prompt[]> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.listPrompts();
@@ -237,7 +250,7 @@ export class SkillMcpManager {
     info: SkillMcpClientInfo,
     config: McpServerConfig,
     name: string,
-    args: Record<string, unknown>
+    args: Record<string, unknown>,
   ): Promise<unknown> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.callTool({ name, arguments: args });
@@ -247,7 +260,7 @@ export class SkillMcpManager {
   async readResource(
     info: SkillMcpClientInfo,
     config: McpServerConfig,
-    uri: string
+    uri: string,
   ): Promise<unknown> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.readResource({ uri });
@@ -258,7 +271,7 @@ export class SkillMcpManager {
     info: SkillMcpClientInfo,
     config: McpServerConfig,
     name: string,
-    args: Record<string, string>
+    args: Record<string, string>,
   ): Promise<unknown> {
     const client = await this.getOrCreateClient(info, config);
     const result = await client.getPrompt({ name, arguments: args });

+ 86 - 59
src/tools/skill/tools.ts

@@ -1,10 +1,22 @@
-import { tool, type ToolDefinition } from "@opencode-ai/plugin";
-import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js";
-import { SKILL_MCP_TOOL_DESCRIPTION, SKILL_TOOL_DESCRIPTION } from "./constants";
-import { getSkillByName, getBuiltinSkills, getSkillsForAgent, canAgentUseSkill } from "./builtin";
-import type { SkillArgs, SkillMcpArgs, SkillDefinition } from "./types";
-import { SkillMcpManager } from "./mcp-manager";
-import type { PluginConfig } from "../../config/schema";
+import type {
+  Prompt,
+  Resource,
+  Tool,
+} from '@modelcontextprotocol/sdk/types.js';
+import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../../config/schema';
+import {
+  canAgentUseSkill,
+  getBuiltinSkills,
+  getSkillByName,
+  getSkillsForAgent,
+} from './builtin';
+import {
+  SKILL_MCP_TOOL_DESCRIPTION,
+  SKILL_TOOL_DESCRIPTION,
+} from './constants';
+import type { SkillMcpManager } from './mcp-manager';
+import type { SkillArgs, SkillDefinition, SkillMcpArgs } from './types';
 
 type ToolContext = {
   sessionID: string;
@@ -14,19 +26,19 @@ type ToolContext = {
 };
 
 function formatSkillsXml(skills: SkillDefinition[]): string {
-  if (skills.length === 0) return "";
+  if (skills.length === 0) return '';
 
   const skillsXml = skills
-    .map(skill => {
+    .map((skill) => {
       const lines = [
-        "  <skill>",
+        '  <skill>',
         `    <name>${skill.name}</name>`,
         `    <description>${skill.description}</description>`,
-        "  </skill>",
+        '  </skill>',
       ];
-      return lines.join("\n");
+      return lines.join('\n');
     })
-    .join("\n");
+    .join('\n');
 
   return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`;
 }
@@ -34,13 +46,13 @@ function formatSkillsXml(skills: SkillDefinition[]): string {
 async function formatMcpCapabilities(
   skill: SkillDefinition,
   manager: SkillMcpManager,
-  sessionId: string
+  sessionId: string,
 ): Promise<string | null> {
   if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
     return null;
   }
 
-  const sections: string[] = ["", "## Available MCP Servers", ""];
+  const sections: string[] = ['', '## Available MCP Servers', ''];
 
   for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
     const info = {
@@ -50,7 +62,7 @@ async function formatMcpCapabilities(
     };
 
     sections.push(`### ${serverName}`);
-    sections.push("");
+    sections.push('');
 
     try {
       const [tools, resources, prompts] = await Promise.all([
@@ -60,93 +72,101 @@ async function formatMcpCapabilities(
       ]);
 
       if (tools.length > 0) {
-        sections.push("**Tools:**");
-        sections.push("");
+        sections.push('**Tools:**');
+        sections.push('');
         for (const t of tools as Tool[]) {
           sections.push(`#### \`${t.name}\``);
           if (t.description) {
             sections.push(t.description);
           }
-          sections.push("");
-          sections.push("**inputSchema:**");
-          sections.push("```json");
+          sections.push('');
+          sections.push('**inputSchema:**');
+          sections.push('```json');
           sections.push(JSON.stringify(t.inputSchema, null, 2));
-          sections.push("```");
-          sections.push("");
+          sections.push('```');
+          sections.push('');
         }
       }
 
       if (resources.length > 0) {
         sections.push(
           `**Resources**: ${(resources as Resource[])
-            .map(r => r.uri)
-            .join(", ")}`
+            .map((r) => r.uri)
+            .join(', ')}`,
         );
       }
 
       if (prompts.length > 0) {
         sections.push(
-          `**Prompts**: ${(prompts as Prompt[]).map(p => p.name).join(", ")}`
+          `**Prompts**: ${(prompts as Prompt[]).map((p) => p.name).join(', ')}`,
         );
       }
 
-      if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
-        sections.push("*No capabilities discovered*");
+      if (
+        tools.length === 0 &&
+        resources.length === 0 &&
+        prompts.length === 0
+      ) {
+        sections.push('*No capabilities discovered*');
       }
     } catch (error) {
-      const errorMessage = error instanceof Error ? error.message : String(error);
-      sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`);
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
+      sections.push(`*Failed to connect: ${errorMessage.split('\n')[0]}*`);
     }
 
-    sections.push("");
+    sections.push('');
     sections.push(
-      `Use \`omos_skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`
+      `Use \`omos_skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`,
     );
-    sections.push("");
+    sections.push('');
   }
 
-  return sections.join("\n");
+  return sections.join('\n');
 }
 
 export function createSkillTools(
   manager: SkillMcpManager,
-  pluginConfig?: PluginConfig
+  pluginConfig?: PluginConfig,
 ): { omos_skill: ToolDefinition; omos_skill_mcp: ToolDefinition } {
   const allSkills = getBuiltinSkills();
   const description =
-    SKILL_TOOL_DESCRIPTION + (allSkills.length > 0 ? formatSkillsXml(allSkills) : "");
+    SKILL_TOOL_DESCRIPTION +
+    (allSkills.length > 0 ? formatSkillsXml(allSkills) : '');
 
   const skill: ToolDefinition = tool({
     description,
     args: {
-      name: tool.schema.string().describe("The skill identifier from available_skills"),
+      name: tool.schema
+        .string()
+        .describe('The skill identifier from available_skills'),
     },
     async execute(args: SkillArgs, toolContext) {
       const tctx = toolContext as ToolContext | undefined;
-      const sessionId = tctx?.sessionID ? String(tctx.sessionID) : "unknown";
-      const agentName = tctx?.agent ?? "orchestrator";
+      const sessionId = tctx?.sessionID ? String(tctx.sessionID) : 'unknown';
+      const agentName = tctx?.agent ?? 'orchestrator';
 
       const skillDefinition = getSkillByName(args.name);
       if (!skillDefinition) {
-        const available = allSkills.map(s => s.name).join(", ");
+        const available = allSkills.map((s) => s.name).join(', ');
         throw new Error(
-          `Skill "${args.name}" not found. Available skills: ${available || "none"}`
+          `Skill "${args.name}" not found. Available skills: ${available || 'none'}`,
         );
       }
 
       // Check if this agent can use this skill
       if (!canAgentUseSkill(agentName, args.name, pluginConfig)) {
         const allowedSkills = getSkillsForAgent(agentName, pluginConfig);
-        const allowedNames = allowedSkills.map(s => s.name).join(", ");
+        const allowedNames = allowedSkills.map((s) => s.name).join(', ');
         throw new Error(
           `Agent "${agentName}" cannot use skill "${args.name}". ` +
-          `Available skills for this agent: ${allowedNames || "none"}`
+            `Available skills for this agent: ${allowedNames || 'none'}`,
         );
       }
 
       const output = [
         `## Skill: ${skillDefinition.name}`,
-        "",
+        '',
         skillDefinition.template.trim(),
       ];
 
@@ -154,48 +174,55 @@ export function createSkillTools(
         const mcpInfo = await formatMcpCapabilities(
           skillDefinition,
           manager,
-          sessionId
+          sessionId,
         );
         if (mcpInfo) {
           output.push(mcpInfo);
         }
       }
 
-      return output.join("\n");
+      return output.join('\n');
     },
   });
 
   const skill_mcp: ToolDefinition = tool({
     description: SKILL_MCP_TOOL_DESCRIPTION,
     args: {
-      skillName: tool.schema.string().describe("Skill name that provides the MCP"),
-      mcpName: tool.schema.string().describe("MCP server name"),
-      toolName: tool.schema.string().describe("Tool name to invoke"),
-      toolArgs: tool.schema.record(tool.schema.string(), tool.schema.any()).optional(),
+      skillName: tool.schema
+        .string()
+        .describe('Skill name that provides the MCP'),
+      mcpName: tool.schema.string().describe('MCP server name'),
+      toolName: tool.schema.string().describe('Tool name to invoke'),
+      toolArgs: tool.schema
+        .record(tool.schema.string(), tool.schema.any())
+        .optional(),
     },
     async execute(args: SkillMcpArgs, toolContext) {
       const tctx = toolContext as ToolContext | undefined;
-      const sessionId = tctx?.sessionID ? String(tctx.sessionID) : "unknown";
-      const agentName = tctx?.agent ?? "orchestrator";
+      const sessionId = tctx?.sessionID ? String(tctx.sessionID) : 'unknown';
+      const agentName = tctx?.agent ?? 'orchestrator';
 
       const skillDefinition = getSkillByName(args.skillName);
       if (!skillDefinition) {
-        const available = allSkills.map(s => s.name).join(", ");
+        const available = allSkills.map((s) => s.name).join(', ');
         throw new Error(
-          `Skill "${args.skillName}" not found. Available skills: ${available || "none"}`
+          `Skill "${args.skillName}" not found. Available skills: ${available || 'none'}`,
         );
       }
 
       // Check if this agent can use this skill
       if (!canAgentUseSkill(agentName, args.skillName, pluginConfig)) {
         throw new Error(
-          `Agent "${agentName}" cannot use skill "${args.skillName}".`
+          `Agent "${agentName}" cannot use skill "${args.skillName}".`,
         );
       }
 
-      if (!skillDefinition.mcpConfig || !skillDefinition.mcpConfig[args.mcpName]) {
+      if (
+        !skillDefinition.mcpConfig ||
+        !skillDefinition.mcpConfig[args.mcpName]
+      ) {
         throw new Error(
-          `Skill "${args.skillName}" has no MCP named "${args.mcpName}".`
+          `Skill "${args.skillName}" has no MCP named "${args.mcpName}".`,
         );
       }
 
@@ -210,10 +237,10 @@ export function createSkillTools(
         info,
         config,
         args.toolName,
-        args.toolArgs || {}
+        args.toolArgs || {},
       );
 
-      if (typeof result === "string") {
+      if (typeof result === 'string') {
         return result;
       }
 

+ 8 - 8
src/tools/skill/types.ts

@@ -1,12 +1,12 @@
-import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
-import type { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
-import type { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
+import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
+import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
+import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
 
 /**
  * Stdio MCP server configuration (local process)
  */
 export interface StdioMcpServer {
-  type?: "stdio";
+  type?: 'stdio';
   command: string;
   args?: string[];
   env?: Record<string, string>;
@@ -16,7 +16,7 @@ export interface StdioMcpServer {
  * HTTP MCP server configuration (remote server)
  */
 export interface HttpMcpServer {
-  type: "http" | "sse";
+  type: 'http' | 'sse';
   url: string;
   headers?: Record<string, string>;
 }
@@ -53,7 +53,7 @@ export interface SkillMcpClientInfo {
 /**
  * Connection type for managed clients
  */
-export type ConnectionType = "stdio" | "http";
+export type ConnectionType = 'stdio' | 'http';
 
 /**
  * Base interface for managed MCP clients
@@ -69,7 +69,7 @@ interface ManagedClientBase {
  * Managed stdio client
  */
 export interface ManagedStdioClient extends ManagedClientBase {
-  connectionType: "stdio";
+  connectionType: 'stdio';
   transport: StdioClientTransport;
 }
 
@@ -77,7 +77,7 @@ export interface ManagedStdioClient extends ManagedClientBase {
  * Managed HTTP client
  */
 export interface ManagedHttpClient extends ManagedClientBase {
-  connectionType: "http";
+  connectionType: 'http';
   transport: StreamableHTTPClientTransport;
 }
 

+ 59 - 59
src/utils/agent-variant.test.ts

@@ -1,140 +1,140 @@
-import { describe, expect, test } from "bun:test";
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from '../config';
 import {
+  applyAgentVariant,
   normalizeAgentName,
   resolveAgentVariant,
-  applyAgentVariant,
-} from "./agent-variant";
-import type { PluginConfig } from "../config";
+} from './agent-variant';
 
-describe("normalizeAgentName", () => {
-  test("returns name unchanged if no @ prefix", () => {
-    expect(normalizeAgentName("oracle")).toBe("oracle");
+describe('normalizeAgentName', () => {
+  test('returns name unchanged if no @ prefix', () => {
+    expect(normalizeAgentName('oracle')).toBe('oracle');
   });
 
-  test("strips @ prefix from agent name", () => {
-    expect(normalizeAgentName("@oracle")).toBe("oracle");
+  test('strips @ prefix from agent name', () => {
+    expect(normalizeAgentName('@oracle')).toBe('oracle');
   });
 
-  test("trims whitespace", () => {
-    expect(normalizeAgentName("  oracle  ")).toBe("oracle");
+  test('trims whitespace', () => {
+    expect(normalizeAgentName('  oracle  ')).toBe('oracle');
   });
 
-  test("handles @ prefix with whitespace", () => {
-    expect(normalizeAgentName("  @explore  ")).toBe("explore");
+  test('handles @ prefix with whitespace', () => {
+    expect(normalizeAgentName('  @explore  ')).toBe('explore');
   });
 
-  test("handles empty string", () => {
-    expect(normalizeAgentName("")).toBe("");
+  test('handles empty string', () => {
+    expect(normalizeAgentName('')).toBe('');
   });
 });
 
-describe("resolveAgentVariant", () => {
-  test("returns undefined when config is undefined", () => {
-    expect(resolveAgentVariant(undefined, "oracle")).toBeUndefined();
+describe('resolveAgentVariant', () => {
+  test('returns undefined when config is undefined', () => {
+    expect(resolveAgentVariant(undefined, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined when agents is undefined", () => {
+  test('returns undefined when agents is undefined', () => {
     const config = {} as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined when agent has no variant", () => {
+  test('returns undefined when agent has no variant', () => {
     const config = {
       agents: {
-        oracle: { model: "gpt-4" },
+        oracle: { model: 'gpt-4' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns variant when configured", () => {
+  test('returns variant when configured', () => {
     const config = {
       agents: {
-        oracle: { variant: "high" },
+        oracle: { variant: 'high' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBe("high");
+    expect(resolveAgentVariant(config, 'oracle')).toBe('high');
   });
 
-  test("normalizes agent name with @ prefix", () => {
+  test('normalizes agent name with @ prefix', () => {
     const config = {
       agents: {
-        oracle: { variant: "low" },
+        oracle: { variant: 'low' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "@oracle")).toBe("low");
+    expect(resolveAgentVariant(config, '@oracle')).toBe('low');
   });
 
-  test("returns undefined for empty string variant", () => {
+  test('returns undefined for empty string variant', () => {
     const config = {
       agents: {
-        oracle: { variant: "" },
+        oracle: { variant: '' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("returns undefined for whitespace-only variant", () => {
+  test('returns undefined for whitespace-only variant', () => {
     const config = {
       agents: {
-        oracle: { variant: "   " },
+        oracle: { variant: '   ' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 
-  test("trims variant whitespace", () => {
+  test('trims variant whitespace', () => {
     const config = {
       agents: {
-        oracle: { variant: "  medium  " },
+        oracle: { variant: '  medium  ' },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBe("medium");
+    expect(resolveAgentVariant(config, 'oracle')).toBe('medium');
   });
 
-  test("returns undefined for non-string variant", () => {
+  test('returns undefined for non-string variant', () => {
     const config = {
       agents: {
         oracle: { variant: 123 as unknown as string },
       },
     } as PluginConfig;
-    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+    expect(resolveAgentVariant(config, 'oracle')).toBeUndefined();
   });
 });
 
-describe("applyAgentVariant", () => {
-  test("returns body unchanged when variant is undefined", () => {
-    const body = { agent: "oracle", parts: [] };
+describe('applyAgentVariant', () => {
+  test('returns body unchanged when variant is undefined', () => {
+    const body = { agent: 'oracle', parts: [] };
     const result = applyAgentVariant(undefined, body);
     expect(result).toEqual(body);
     expect(result).toBe(body); // Same reference
   });
 
-  test("returns body unchanged when body already has variant", () => {
-    const body = { agent: "oracle", variant: "medium", parts: [] };
-    const result = applyAgentVariant("high", body);
-    expect(result.variant).toBe("medium");
+  test('returns body unchanged when body already has variant', () => {
+    const body = { agent: 'oracle', variant: 'medium', parts: [] };
+    const result = applyAgentVariant('high', body);
+    expect(result.variant).toBe('medium');
     expect(result).toBe(body); // Same reference
   });
 
-  test("applies variant to body without variant", () => {
-    const body = { agent: "oracle", parts: [] };
-    const result = applyAgentVariant("high", body);
-    expect(result.variant).toBe("high");
-    expect(result.agent).toBe("oracle");
+  test('applies variant to body without variant', () => {
+    const body = { agent: 'oracle', parts: [] };
+    const result = applyAgentVariant('high', body);
+    expect(result.variant).toBe('high');
+    expect(result.agent).toBe('oracle');
     expect(result).not.toBe(body); // New object
   });
 
-  test("preserves all existing body properties", () => {
+  test('preserves all existing body properties', () => {
     const body = {
-      agent: "oracle",
-      parts: [{ type: "text" as const, text: "hello" }],
+      agent: 'oracle',
+      parts: [{ type: 'text' as const, text: 'hello' }],
       tools: { background_task: false },
     };
-    const result = applyAgentVariant("low", body);
-    expect(result.agent).toBe("oracle");
-    expect(result.parts).toEqual([{ type: "text", text: "hello" }]);
+    const result = applyAgentVariant('low', body);
+    expect(result.agent).toBe('oracle');
+    expect(result.parts).toEqual([{ type: 'text', text: 'hello' }]);
     expect(result.tools).toEqual({ background_task: false });
-    expect(result.variant).toBe("low");
+    expect(result.variant).toBe('low');
   });
 });

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

@@ -1,5 +1,5 @@
-import type { PluginConfig } from "../config";
-import { log } from "./logger";
+import type { PluginConfig } from '../config';
+import { log } from './logger';
 
 /**
  * Normalizes an agent name by trimming whitespace and removing the optional @ prefix.
@@ -13,7 +13,7 @@ import { log } from "./logger";
  */
 export function normalizeAgentName(agentName: string): string {
   const trimmed = agentName.trim();
-  return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
+  return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
 }
 
 /**
@@ -34,12 +34,12 @@ export function normalizeAgentName(agentName: string): string {
  */
 export function resolveAgentVariant(
   config: PluginConfig | undefined,
-  agentName: string
+  agentName: string,
 ): string | undefined {
   const normalized = normalizeAgentName(agentName);
   const rawVariant = config?.agents?.[normalized]?.variant;
 
-  if (typeof rawVariant !== "string") {
+  if (typeof rawVariant !== 'string') {
     return undefined;
   }
 
@@ -69,7 +69,7 @@ export function resolveAgentVariant(
  */
 export function applyAgentVariant<T extends { variant?: string }>(
   variant: string | undefined,
-  body: T
+  body: T,
 ): T {
   if (!variant) {
     return body;

+ 5 - 5
src/utils/index.ts

@@ -1,5 +1,5 @@
-export * from "./polling";
-export * from "./tmux";
-export * from "./agent-variant";
-export { log } from "./logger";
-export { extractZip } from "./zip-extractor";
+export * from './agent-variant';
+export { log } from './logger';
+export * from './polling';
+export * from './tmux';
+export { extractZip } from './zip-extractor';

+ 122 - 120
src/utils/logger.test.ts

@@ -1,121 +1,123 @@
-import { describe, expect, test, beforeEach, afterEach } from "bun:test";
-import * as fs from "fs";
-import * as os from "os";
-import * as path from "path";
-import { log } from "./logger";
-
-describe("logger", () => {
-    const testLogFile = path.join(os.tmpdir(), "oh-my-opencode-slim.log");
-
-    beforeEach(() => {
-        // Clean up log file before each test
-        if (fs.existsSync(testLogFile)) {
-            fs.unlinkSync(testLogFile);
-        }
-    });
-
-    afterEach(() => {
-        // Clean up log file after each test
-        if (fs.existsSync(testLogFile)) {
-            fs.unlinkSync(testLogFile);
-        }
-    });
-
-    test("writes log message to file", () => {
-        log("test message");
-
-        expect(fs.existsSync(testLogFile)).toBe(true);
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toContain("test message");
-    });
-
-    test("includes timestamp in log entry", () => {
-        log("timestamped message");
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        // Check for ISO timestamp format [YYYY-MM-DDTHH:MM:SS.sssZ]
-        expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/);
-    });
-
-    test("logs message with data object", () => {
-        log("message with data", { key: "value", number: 42 });
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toContain("message with data");
-        expect(content).toContain('"key":"value"');
-        expect(content).toContain('"number":42');
-    });
-
-    test("logs message without data", () => {
-        log("message without data");
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toContain("message without data");
-        // Should not have extra JSON at the end
-        expect(content.trim()).toMatch(/message without data\s*$/);
-    });
-
-    test("appends multiple log entries", () => {
-        log("first message");
-        log("second message");
-        log("third message");
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        const lines = content.trim().split("\n");
-        expect(lines.length).toBe(3);
-        expect(lines[0]).toContain("first message");
-        expect(lines[1]).toContain("second message");
-        expect(lines[2]).toContain("third message");
-    });
-
-    test("handles complex data structures", () => {
-        const complexData = {
-            nested: { deep: { value: "test" } },
-            array: [1, 2, 3],
-            boolean: true,
-            null: null,
-        };
-
-        log("complex data", complexData);
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toContain("complex data");
-        expect(content).toContain('"nested":');
-        expect(content).toContain('"array":[1,2,3]');
-        expect(content).toContain('"boolean":true');
-    });
-
-    test("handles special characters in message", () => {
-        log("message with special chars: @#$%^&*()");
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toContain("message with special chars: @#$%^&*()");
-    });
-
-    test("handles empty string message", () => {
-        log("");
-
-        const content = fs.readFileSync(testLogFile, "utf-8");
-        expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]\s+\n/);
-    });
-
-    test("does not throw when logging fails", () => {
-        // Make the log directory read-only to force a write error
-        // This test is platform-dependent and might not work on all systems
-        // So we'll just verify that log() doesn't throw
-        expect(() => {
-            log("test message", { data: "value" });
-        }).not.toThrow();
-    });
-
-    test("handles circular references in data", () => {
-        const circular: any = { name: "test" };
-        circular.self = circular;
-
-        // JSON.stringify will throw on circular references
-        // The logger should handle this gracefully (catch block)
-        expect(() => {
-            log("circular data", circular);
-        }).not.toThrow();
-    });
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { log } from './logger';
+
+describe('logger', () => {
+  const testLogFile = path.join(os.tmpdir(), 'oh-my-opencode-slim.log');
+
+  beforeEach(() => {
+    // Clean up log file before each test
+    if (fs.existsSync(testLogFile)) {
+      fs.unlinkSync(testLogFile);
+    }
+  });
+
+  afterEach(() => {
+    // Clean up log file after each test
+    if (fs.existsSync(testLogFile)) {
+      fs.unlinkSync(testLogFile);
+    }
+  });
+
+  test('writes log message to file', () => {
+    log('test message');
+
+    expect(fs.existsSync(testLogFile)).toBe(true);
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toContain('test message');
+  });
+
+  test('includes timestamp in log entry', () => {
+    log('timestamped message');
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    // Check for ISO timestamp format [YYYY-MM-DDTHH:MM:SS.sssZ]
+    expect(content).toMatch(/\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]/);
+  });
+
+  test('logs message with data object', () => {
+    log('message with data', { key: 'value', number: 42 });
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toContain('message with data');
+    expect(content).toContain('"key":"value"');
+    expect(content).toContain('"number":42');
+  });
+
+  test('logs message without data', () => {
+    log('message without data');
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toContain('message without data');
+    // Should not have extra JSON at the end
+    expect(content.trim()).toMatch(/message without data\s*$/);
+  });
+
+  test('appends multiple log entries', () => {
+    log('first message');
+    log('second message');
+    log('third message');
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    const lines = content.trim().split('\n');
+    expect(lines.length).toBe(3);
+    expect(lines[0]).toContain('first message');
+    expect(lines[1]).toContain('second message');
+    expect(lines[2]).toContain('third message');
+  });
+
+  test('handles complex data structures', () => {
+    const complexData = {
+      nested: { deep: { value: 'test' } },
+      array: [1, 2, 3],
+      boolean: true,
+      null: null,
+    };
+
+    log('complex data', complexData);
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toContain('complex data');
+    expect(content).toContain('"nested":');
+    expect(content).toContain('"array":[1,2,3]');
+    expect(content).toContain('"boolean":true');
+  });
+
+  test('handles special characters in message', () => {
+    log('message with special chars: @#$%^&*()');
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toContain('message with special chars: @#$%^&*()');
+  });
+
+  test('handles empty string message', () => {
+    log('');
+
+    const content = fs.readFileSync(testLogFile, 'utf-8');
+    expect(content).toMatch(
+      /\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\]\s+\n/,
+    );
+  });
+
+  test('does not throw when logging fails', () => {
+    // Make the log directory read-only to force a write error
+    // This test is platform-dependent and might not work on all systems
+    // So we'll just verify that log() doesn't throw
+    expect(() => {
+      log('test message', { data: 'value' });
+    }).not.toThrow();
+  });
+
+  test('handles circular references in data', () => {
+    const circular: any = { name: 'test' };
+    circular.self = circular;
+
+    // JSON.stringify will throw on circular references
+    // The logger should handle this gracefully (catch block)
+    expect(() => {
+      log('circular data', circular);
+    }).not.toThrow();
+  });
 });

+ 7 - 7
src/utils/logger.ts

@@ -1,14 +1,14 @@
-import * as fs from "fs"
-import * as os from "os"
-import * as path from "path"
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
 
-const logFile = path.join(os.tmpdir(), "oh-my-opencode-slim.log")
+const logFile = path.join(os.tmpdir(), 'oh-my-opencode-slim.log');
 
 export function log(message: string, data?: unknown): void {
   try {
-    const timestamp = new Date().toISOString()
-    const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
-    fs.appendFileSync(logFile, logEntry)
+    const timestamp = new Date().toISOString();
+    const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ''}\n`;
+    fs.appendFileSync(logFile, logEntry);
   } catch {
     // Silently ignore logging errors
   }

+ 171 - 163
src/utils/polling.test.ts

@@ -1,183 +1,191 @@
-import { describe, expect, test, beforeEach } from "bun:test";
-import { pollUntilStable, delay } from "./polling";
-
-describe("pollUntilStable", () => {
-    test("returns success when condition becomes stable", async () => {
-        let callCount = 0;
-        const fetchFn = async () => {
-            callCount++;
-            return callCount >= 3 ? "stable" : "changing";
-        };
-
-        const isStable = (current: string, previous: string | null) => {
-            return current === "stable" && previous === "stable";
-        };
-
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 1000,
-            stableThreshold: 2,
-        });
-
-        expect(result.success).toBe(true);
-        expect(result.data).toBe("stable");
-        expect(result.timedOut).toBeUndefined();
-        expect(result.aborted).toBeUndefined();
+import { describe, expect, test } from 'bun:test';
+import { delay, pollUntilStable } from './polling';
+
+describe('pollUntilStable', () => {
+  test('returns success when condition becomes stable', async () => {
+    let callCount = 0;
+    const fetchFn = async () => {
+      callCount++;
+      return callCount >= 3 ? 'stable' : 'changing';
+    };
+
+    const isStable = (current: string, previous: string | null) => {
+      return current === 'stable' && previous === 'stable';
+    };
+
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 1000,
+      stableThreshold: 2,
     });
 
-    test("returns timeout when max poll time exceeded", async () => {
-        const fetchFn = async () => "always-changing";
-        const isStable = () => false; // Never stable
+    expect(result.success).toBe(true);
+    expect(result.data).toBe('stable');
+    expect(result.timedOut).toBeUndefined();
+    expect(result.aborted).toBeUndefined();
+  });
 
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 50, // Very short timeout
-            stableThreshold: 2,
-        });
+  test('returns timeout when max poll time exceeded', async () => {
+    const fetchFn = async () => 'always-changing';
+    const isStable = () => false; // Never stable
 
-        expect(result.success).toBe(false);
-        expect(result.timedOut).toBe(true);
-        expect(result.data).toBe("always-changing");
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 50, // Very short timeout
+      stableThreshold: 2,
     });
 
-    test("returns aborted when signal is aborted", async () => {
-        const controller = new AbortController();
-        const fetchFn = async () => {
-            // Abort after first call
-            controller.abort();
-            return "data";
-        };
-
-        const isStable = () => false;
-
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 1000,
-            signal: controller.signal,
-        });
-
-        expect(result.success).toBe(false);
-        expect(result.aborted).toBe(true);
+    expect(result.success).toBe(false);
+    expect(result.timedOut).toBe(true);
+    expect(result.data).toBe('always-changing');
+  });
+
+  test('returns aborted when signal is aborted', async () => {
+    const controller = new AbortController();
+    const fetchFn = async () => {
+      // Abort after first call
+      controller.abort();
+      return 'data';
+    };
+
+    const isStable = () => false;
+
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 1000,
+      signal: controller.signal,
     });
 
-    test("respects custom stability threshold", async () => {
-        let callCount = 0;
-        const fetchFn = async () => {
-            callCount++;
-            return callCount >= 2 ? "stable" : "changing";
-        };
-
-        const isStable = (current: string, previous: string | null, stableCount: number) => {
-            return current === "stable" && previous === "stable";
-        };
-
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 1000,
-            stableThreshold: 3, // Require 3 stable polls
-        });
-
-        expect(result.success).toBe(true);
-        expect(callCount).toBeGreaterThanOrEqual(5); // At least 2 changing + 3 stable
+    expect(result.success).toBe(false);
+    expect(result.aborted).toBe(true);
+  });
+
+  test('respects custom stability threshold', async () => {
+    let callCount = 0;
+    const fetchFn = async () => {
+      callCount++;
+      return callCount >= 2 ? 'stable' : 'changing';
+    };
+
+    const isStable = (
+      current: string,
+      previous: string | null,
+      _stableCount: number,
+    ) => {
+      return current === 'stable' && previous === 'stable';
+    };
+
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 1000,
+      stableThreshold: 3, // Require 3 stable polls
     });
 
-    test("resets stable count when condition becomes unstable", async () => {
-        let callCount = 0;
-        const values = ["a", "a", "b", "b", "b", "b"]; // Unstable, then stable
-        const fetchFn = async () => values[callCount++] || "b";
+    expect(result.success).toBe(true);
+    expect(callCount).toBeGreaterThanOrEqual(5); // At least 2 changing + 3 stable
+  });
 
-        const isStable = (current: string, previous: string | null) => {
-            return current === previous && current === "b";
-        };
+  test('resets stable count when condition becomes unstable', async () => {
+    let callCount = 0;
+    const values = ['a', 'a', 'b', 'b', 'b', 'b']; // Unstable, then stable
+    const fetchFn = async () => values[callCount++] || 'b';
 
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 1000,
-            stableThreshold: 3,
-        });
+    const isStable = (current: string, previous: string | null) => {
+      return current === previous && current === 'b';
+    };
 
-        expect(result.success).toBe(true);
-        expect(result.data).toBe("b");
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 1000,
+      stableThreshold: 3,
     });
 
-    test("uses default options when not provided", async () => {
-        let callCount = 0;
-        const fetchFn = async () => {
-            callCount++;
-            return callCount >= 2 ? "stable" : "changing";
-        };
-
-        const isStable = (current: string, previous: string | null) => {
-            return current === "stable" && previous === "stable";
-        };
-
-        const result = await pollUntilStable(fetchFn, isStable);
-
-        expect(result.success).toBe(true);
-        expect(result.data).toBe("stable");
-    });
-
-    test("handles fetchFn that throws errors", async () => {
-        const fetchFn = async () => {
-            throw new Error("Fetch failed");
-        };
-
-        const isStable = () => false;
-
-        await expect(
-            pollUntilStable(fetchFn, isStable, {
-                pollInterval: 10,
-                maxPollTime: 100,
-            })
-        ).rejects.toThrow("Fetch failed");
+    expect(result.success).toBe(true);
+    expect(result.data).toBe('b');
+  });
+
+  test('uses default options when not provided', async () => {
+    let callCount = 0;
+    const fetchFn = async () => {
+      callCount++;
+      return callCount >= 2 ? 'stable' : 'changing';
+    };
+
+    const isStable = (current: string, previous: string | null) => {
+      return current === 'stable' && previous === 'stable';
+    };
+
+    const result = await pollUntilStable(fetchFn, isStable);
+
+    expect(result.success).toBe(true);
+    expect(result.data).toBe('stable');
+  });
+
+  test('handles fetchFn that throws errors', async () => {
+    const fetchFn = async () => {
+      throw new Error('Fetch failed');
+    };
+
+    const isStable = () => false;
+
+    await expect(
+      pollUntilStable(fetchFn, isStable, {
+        pollInterval: 10,
+        maxPollTime: 100,
+      }),
+    ).rejects.toThrow('Fetch failed');
+  });
+
+  test('passes stable count to isStable function', async () => {
+    let _callCount = 0;
+    const fetchFn = async () => {
+      _callCount++;
+      return 'data';
+    };
+
+    let maxStableCount = 0;
+    const isStable = (
+      current: string,
+      previous: string | null,
+      stableCount: number,
+    ) => {
+      maxStableCount = Math.max(maxStableCount, stableCount);
+      // Check if data is actually stable (same as previous)
+      return current === previous && current === 'data';
+    };
+
+    const result = await pollUntilStable(fetchFn, isStable, {
+      pollInterval: 10,
+      maxPollTime: 1000,
+      stableThreshold: 3,
     });
 
-    test("passes stable count to isStable function", async () => {
-        let callCount = 0;
-        const fetchFn = async () => {
-            callCount++;
-            return "data";
-        };
-
-        let maxStableCount = 0;
-        const isStable = (current: string, previous: string | null, stableCount: number) => {
-            maxStableCount = Math.max(maxStableCount, stableCount);
-            // Check if data is actually stable (same as previous)
-            return current === previous && current === "data";
-        };
-
-        const result = await pollUntilStable(fetchFn, isStable, {
-            pollInterval: 10,
-            maxPollTime: 1000,
-            stableThreshold: 3,
-        });
-
-        expect(result.success).toBe(true);
-        expect(maxStableCount).toBeGreaterThanOrEqual(2);
-    });
+    expect(result.success).toBe(true);
+    expect(maxStableCount).toBeGreaterThanOrEqual(2);
+  });
 });
 
-describe("delay", () => {
-    test("delays for specified milliseconds", async () => {
-        const start = Date.now();
-        await delay(50);
-        const elapsed = Date.now() - start;
-
-        // Allow some tolerance for timing
-        expect(elapsed).toBeGreaterThanOrEqual(45);
-        expect(elapsed).toBeLessThan(100);
-    });
-
-    test("resolves without value", async () => {
-        const result = await delay(10);
-        expect(result).toBeUndefined();
-    });
-
-    test("can be used in promise chains", async () => {
-        const result = await Promise.resolve("test")
-            .then((val) => delay(10).then(() => val))
-            .then((val) => val.toUpperCase());
-
-        expect(result).toBe("TEST");
-    });
+describe('delay', () => {
+  test('delays for specified milliseconds', async () => {
+    const start = Date.now();
+    await delay(50);
+    const elapsed = Date.now() - start;
+
+    // Allow some tolerance for timing
+    expect(elapsed).toBeGreaterThanOrEqual(45);
+    expect(elapsed).toBeLessThan(100);
+  });
+
+  test('resolves without value', async () => {
+    const result = await delay(10);
+    expect(result).toBeUndefined();
+  });
+
+  test('can be used in promise chains', async () => {
+    const result = await Promise.resolve('test')
+      .then((val) => delay(10).then(() => val))
+      .then((val) => val.toUpperCase());
+
+    expect(result).toBe('TEST');
+  });
 });

+ 3 - 3
src/utils/polling.ts

@@ -1,8 +1,8 @@
 import {
-  POLL_INTERVAL_MS,
   MAX_POLL_TIME_MS,
+  POLL_INTERVAL_MS,
   STABLE_POLLS_THRESHOLD,
-} from "../config";
+} from '../config';
 
 export interface PollOptions {
   pollInterval?: number;
@@ -25,7 +25,7 @@ export interface PollResult<T> {
 export async function pollUntilStable<T>(
   fetchFn: () => Promise<T>,
   isStable: (current: T, previous: T | null, stableCount: number) => boolean,
-  opts: PollOptions = {}
+  opts: PollOptions = {},
 ): Promise<PollResult<T>> {
   const pollInterval = opts.pollInterval ?? POLL_INTERVAL_MS;
   const maxPollTime = opts.maxPollTime ?? MAX_POLL_TIME_MS;

+ 25 - 25
src/utils/tmux.test.ts

@@ -1,31 +1,31 @@
-import { describe, expect, test } from "bun:test";
-import { isInsideTmux, resetServerCheck } from "./tmux";
+import { describe, expect, test } from 'bun:test';
+import { resetServerCheck } from './tmux';
 
-describe("tmux utils", () => {
-    describe("resetServerCheck", () => {
-        test("resetServerCheck is exported and is a function", () => {
-            expect(typeof resetServerCheck).toBe("function");
-        });
+describe('tmux utils', () => {
+  describe('resetServerCheck', () => {
+    test('resetServerCheck is exported and is a function', () => {
+      expect(typeof resetServerCheck).toBe('function');
+    });
 
-        test("resetServerCheck does not throw", () => {
-            expect(() => resetServerCheck()).not.toThrow();
-        });
+    test('resetServerCheck does not throw', () => {
+      expect(() => resetServerCheck()).not.toThrow();
+    });
 
-        test("can be called multiple times", () => {
-            expect(() => {
-                resetServerCheck();
-                resetServerCheck();
-                resetServerCheck();
-            }).not.toThrow();
-        });
+    test('can be called multiple times', () => {
+      expect(() => {
+        resetServerCheck();
+        resetServerCheck();
+        resetServerCheck();
+      }).not.toThrow();
     });
+  });
 
-    // Note: Testing getTmuxPath, spawnTmuxPane, and closeTmuxPane requires:
-    // 1. Mocking Bun's spawn function
-    // 2. Mocking file system operations
-    // 3. Running in a tmux environment
-    // 4. Mocking HTTP fetch for server checks
-    //
-    // These are better suited for integration tests rather than unit tests.
-    // The current tests cover the simple, pure functions that don't require mocking.
+  // Note: Testing getTmuxPath, spawnTmuxPane, and closeTmuxPane requires:
+  // 1. Mocking Bun's spawn function
+  // 2. Mocking file system operations
+  // 3. Running in a tmux environment
+  // 4. Mocking HTTP fetch for server checks
+  //
+  // These are better suited for integration tests rather than unit tests.
+  // The current tests cover the simple, pure functions that don't require mocking.
 });

+ 97 - 74
src/utils/tmux.ts

@@ -1,6 +1,6 @@
-import { spawn } from "bun";
-import { log } from "./logger";
-import type { TmuxConfig, TmuxLayout } from "../config/schema";
+import { spawn } from 'bun';
+import type { TmuxConfig, TmuxLayout } from '../config/schema';
+import { log } from './logger';
 
 let tmuxPath: string | null = null;
 let tmuxChecked = false;
@@ -22,7 +22,7 @@ async function isServerRunning(serverUrl: string): Promise<boolean> {
     return true;
   }
 
-  const healthUrl = new URL("/health", serverUrl).toString();
+  const healthUrl = new URL('/health', serverUrl).toString();
   const timeoutMs = 3000;
   const maxAttempts = 2;
 
@@ -32,7 +32,9 @@ async function isServerRunning(serverUrl: string): Promise<boolean> {
 
     let response: Response | null = null;
     try {
-      response = await fetch(healthUrl, { signal: controller.signal }).catch(() => null);
+      response = await fetch(healthUrl, { signal: controller.signal }).catch(
+        () => null,
+      );
     } finally {
       clearTimeout(timeout);
     }
@@ -41,7 +43,7 @@ async function isServerRunning(serverUrl: string): Promise<boolean> {
     if (available) {
       serverCheckUrl = serverUrl;
       serverAvailable = true;
-      log("[tmux] isServerRunning: checked", { serverUrl, available, attempt });
+      log('[tmux] isServerRunning: checked', { serverUrl, available, attempt });
       return true;
     }
 
@@ -50,7 +52,7 @@ async function isServerRunning(serverUrl: string): Promise<boolean> {
     }
   }
 
-  log("[tmux] isServerRunning: checked", { serverUrl, available: false });
+  log('[tmux] isServerRunning: checked', { serverUrl, available: false });
   return false;
 }
 
@@ -66,13 +68,13 @@ export function resetServerCheck(): void {
  * Find tmux binary path
  */
 async function findTmuxPath(): Promise<string | null> {
-  const isWindows = process.platform === "win32";
-  const cmd = isWindows ? "where" : "which";
+  const isWindows = process.platform === 'win32';
+  const cmd = isWindows ? 'where' : 'which';
 
   try {
-    const proc = spawn([cmd, "tmux"], {
-      stdout: "pipe",
-      stderr: "pipe",
+    const proc = spawn([cmd, 'tmux'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
 
     const exitCode = await proc.exited;
@@ -82,27 +84,27 @@ async function findTmuxPath(): Promise<string | null> {
     }
 
     const stdout = await new Response(proc.stdout).text();
-    const path = stdout.trim().split("\n")[0];
+    const path = stdout.trim().split('\n')[0];
     if (!path) {
-      log("[tmux] findTmuxPath: no path in output");
+      log('[tmux] findTmuxPath: no path in output');
       return null;
     }
 
     // Verify it works
-    const verifyProc = spawn([path, "-V"], {
-      stdout: "pipe",
-      stderr: "pipe",
+    const verifyProc = spawn([path, '-V'], {
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
     const verifyExit = await verifyProc.exited;
     if (verifyExit !== 0) {
-      log("[tmux] findTmuxPath: tmux -V failed", { path, verifyExit });
+      log('[tmux] findTmuxPath: tmux -V failed', { path, verifyExit });
       return null;
     }
 
-    log("[tmux] findTmuxPath: found tmux", { path });
+    log('[tmux] findTmuxPath: found tmux', { path });
     return path;
   } catch (err) {
-    log("[tmux] findTmuxPath: exception", { error: String(err) });
+    log('[tmux] findTmuxPath: exception', { error: String(err) });
     return null;
   }
 }
@@ -117,7 +119,7 @@ export async function getTmuxPath(): Promise<string | null> {
 
   tmuxPath = await findTmuxPath();
   tmuxChecked = true;
-  log("[tmux] getTmuxPath: initialized", { tmuxPath });
+  log('[tmux] getTmuxPath: initialized', { tmuxPath });
   return tmuxPath;
 }
 
@@ -131,38 +133,44 @@ export function isInsideTmux(): boolean {
 /**
  * Apply a tmux layout to the current window
  */
-async function applyLayout(tmux: string, layout: TmuxLayout, mainPaneSize: number): Promise<void> {
+async function applyLayout(
+  tmux: string,
+  layout: TmuxLayout,
+  mainPaneSize: number,
+): Promise<void> {
   try {
     // Apply the layout
-    const layoutProc = spawn([tmux, "select-layout", layout], {
-      stdout: "pipe",
-      stderr: "pipe",
+    const layoutProc = spawn([tmux, 'select-layout', layout], {
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
     await layoutProc.exited;
 
     // For main-* layouts, set the main pane size
-    if (layout === "main-horizontal" || layout === "main-vertical") {
-      const sizeOption = layout === "main-horizontal"
-        ? "main-pane-height"
-        : "main-pane-width";
-
-      const sizeProc = spawn([tmux, "set-window-option", sizeOption, `${mainPaneSize}%`], {
-        stdout: "pipe",
-        stderr: "pipe",
-      });
+    if (layout === 'main-horizontal' || layout === 'main-vertical') {
+      const sizeOption =
+        layout === 'main-horizontal' ? 'main-pane-height' : 'main-pane-width';
+
+      const sizeProc = spawn(
+        [tmux, 'set-window-option', sizeOption, `${mainPaneSize}%`],
+        {
+          stdout: 'pipe',
+          stderr: 'pipe',
+        },
+      );
       await sizeProc.exited;
 
       // Reapply layout to use the new size
-      const reapplyProc = spawn([tmux, "select-layout", layout], {
-        stdout: "pipe",
-        stderr: "pipe",
+      const reapplyProc = spawn([tmux, 'select-layout', layout], {
+        stdout: 'pipe',
+        stderr: 'pipe',
       });
       await reapplyProc.exited;
     }
 
-    log("[tmux] applyLayout: applied", { layout, mainPaneSize });
+    log('[tmux] applyLayout: applied', { layout, mainPaneSize });
   } catch (err) {
-    log("[tmux] applyLayout: exception", { error: String(err) });
+    log('[tmux] applyLayout: exception', { error: String(err) });
   }
 }
 
@@ -181,17 +189,22 @@ export async function spawnTmuxPane(
   sessionId: string,
   description: string,
   config: TmuxConfig,
-  serverUrl: string
+  serverUrl: string,
 ): Promise<SpawnPaneResult> {
-  log("[tmux] spawnTmuxPane called", { sessionId, description, config, serverUrl });
+  log('[tmux] spawnTmuxPane called', {
+    sessionId,
+    description,
+    config,
+    serverUrl,
+  });
 
   if (!config.enabled) {
-    log("[tmux] spawnTmuxPane: config.enabled is false, skipping");
+    log('[tmux] spawnTmuxPane: config.enabled is false, skipping');
     return { success: false };
   }
 
   if (!isInsideTmux()) {
-    log("[tmux] spawnTmuxPane: not inside tmux, skipping");
+    log('[tmux] spawnTmuxPane: not inside tmux, skipping');
     return { success: false };
   }
 
@@ -199,17 +212,17 @@ export async function spawnTmuxPane(
   // This is needed because serverUrl may be a fallback even when no server is running
   const serverRunning = await isServerRunning(serverUrl);
   if (!serverRunning) {
-    const defaultPort = process.env.OPENCODE_PORT ?? "4096";
-    log("[tmux] spawnTmuxPane: OpenCode server not running, skipping", {
+    const defaultPort = process.env.OPENCODE_PORT ?? '4096';
+    log('[tmux] spawnTmuxPane: OpenCode server not running, skipping', {
       serverUrl,
-      hint: `Start opencode with --port ${defaultPort}`
+      hint: `Start opencode with --port ${defaultPort}`,
     });
     return { success: false };
   }
 
   const tmux = await getTmuxPath();
   if (!tmux) {
-    log("[tmux] spawnTmuxPane: tmux binary not found, skipping");
+    log('[tmux] spawnTmuxPane: tmux binary not found, skipping');
     return { success: false };
   }
 
@@ -224,19 +237,20 @@ export async function spawnTmuxPane(
     // Simple split - layout will handle positioning
     // Use -h for horizontal split (new pane to the right) as default
     const args = [
-      "split-window",
-      "-h",
-      "-d", // Don't switch focus to new pane
-      "-P", // Print pane info
-      "-F", "#{pane_id}", // Format: just the pane ID
+      'split-window',
+      '-h',
+      '-d', // Don't switch focus to new pane
+      '-P', // Print pane info
+      '-F',
+      '#{pane_id}', // Format: just the pane ID
       opencodeCmd,
     ];
 
-    log("[tmux] spawnTmuxPane: executing", { tmux, args, opencodeCmd });
+    log('[tmux] spawnTmuxPane: executing', { tmux, args, opencodeCmd });
 
     const proc = spawn([tmux, ...args], {
-      stdout: "pipe",
-      stderr: "pipe",
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
 
     const exitCode = await proc.exited;
@@ -244,28 +258,35 @@ export async function spawnTmuxPane(
     const stderr = await new Response(proc.stderr).text();
     const paneId = stdout.trim(); // e.g., "%42"
 
-    log("[tmux] spawnTmuxPane: split result", { exitCode, paneId, stderr: stderr.trim() });
+    log('[tmux] spawnTmuxPane: split result', {
+      exitCode,
+      paneId,
+      stderr: stderr.trim(),
+    });
 
     if (exitCode === 0 && paneId) {
       // Rename the pane for visibility
       const renameProc = spawn(
-        [tmux, "select-pane", "-t", paneId, "-T", description.slice(0, 30)],
-        { stdout: "ignore", stderr: "ignore" }
+        [tmux, 'select-pane', '-t', paneId, '-T', description.slice(0, 30)],
+        { stdout: 'ignore', stderr: 'ignore' },
       );
       await renameProc.exited;
 
       // Apply layout to auto-rebalance all panes
-      const layout = config.layout ?? "main-vertical";
+      const layout = config.layout ?? 'main-vertical';
       const mainPaneSize = config.main_pane_size ?? 60;
       await applyLayout(tmux, layout, mainPaneSize);
 
-      log("[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied", { paneId, layout });
+      log('[tmux] spawnTmuxPane: SUCCESS, pane created and layout applied', {
+        paneId,
+        layout,
+      });
       return { success: true, paneId };
     }
 
     return { success: false };
   } catch (err) {
-    log("[tmux] spawnTmuxPane: exception", { error: String(err) });
+    log('[tmux] spawnTmuxPane: exception', { error: String(err) });
     return { success: false };
   }
 }
@@ -274,49 +295,51 @@ export async function spawnTmuxPane(
  * Close a tmux pane by its ID and reapply layout to rebalance remaining panes
  */
 export async function closeTmuxPane(paneId: string): Promise<boolean> {
-  log("[tmux] closeTmuxPane called", { paneId });
+  log('[tmux] closeTmuxPane called', { paneId });
 
   if (!paneId) {
-    log("[tmux] closeTmuxPane: no paneId provided");
+    log('[tmux] closeTmuxPane: no paneId provided');
     return false;
   }
 
   const tmux = await getTmuxPath();
   if (!tmux) {
-    log("[tmux] closeTmuxPane: tmux binary not found");
+    log('[tmux] closeTmuxPane: tmux binary not found');
     return false;
   }
 
   try {
-    const proc = spawn([tmux, "kill-pane", "-t", paneId], {
-      stdout: "pipe",
-      stderr: "pipe",
+    const proc = spawn([tmux, 'kill-pane', '-t', paneId], {
+      stdout: 'pipe',
+      stderr: 'pipe',
     });
 
     const exitCode = await proc.exited;
     const stderr = await new Response(proc.stderr).text();
 
-    log("[tmux] closeTmuxPane: result", { exitCode, stderr: stderr.trim() });
+    log('[tmux] closeTmuxPane: result', { exitCode, stderr: stderr.trim() });
 
     if (exitCode === 0) {
-      log("[tmux] closeTmuxPane: SUCCESS, pane closed", { paneId });
+      log('[tmux] closeTmuxPane: SUCCESS, pane closed', { paneId });
 
       // Reapply layout to rebalance remaining panes
       if (storedConfig) {
-        const layout = storedConfig.layout ?? "main-vertical";
+        const layout = storedConfig.layout ?? 'main-vertical';
         const mainPaneSize = storedConfig.main_pane_size ?? 60;
         await applyLayout(tmux, layout, mainPaneSize);
-        log("[tmux] closeTmuxPane: layout reapplied", { layout });
+        log('[tmux] closeTmuxPane: layout reapplied', { layout });
       }
 
       return true;
     }
 
     // Pane might already be closed (user closed it manually, or process exited)
-    log("[tmux] closeTmuxPane: failed (pane may already be closed)", { paneId });
+    log('[tmux] closeTmuxPane: failed (pane may already be closed)', {
+      paneId,
+    });
     return false;
   } catch (err) {
-    log("[tmux] closeTmuxPane: exception", { error: String(err) });
+    log('[tmux] closeTmuxPane: exception', { error: String(err) });
     return false;
   }
 }
@@ -326,6 +349,6 @@ export async function closeTmuxPane(paneId: string): Promise<boolean> {
  */
 export function startTmuxCheck(): void {
   if (!tmuxChecked) {
-    getTmuxPath().catch(() => { });
+    getTmuxPath().catch(() => {});
   }
 }

+ 55 - 50
src/utils/zip-extractor.ts

@@ -1,97 +1,102 @@
-import { spawn, spawnSync } from "bun"
-import { release } from "os"
+import { release } from 'node:os';
+import { spawn, spawnSync } from 'bun';
 
-const WINDOWS_BUILD_WITH_TAR = 17134
+const WINDOWS_BUILD_WITH_TAR = 17134;
 
 function getWindowsBuildNumber(): number | null {
-  if (process.platform !== "win32") return null
+  if (process.platform !== 'win32') return null;
 
-  const parts = release().split(".")
+  const parts = release().split('.');
   if (parts.length >= 3) {
-    const build = parseInt(parts[2], 10)
-    if (!isNaN(build)) return build
+    const build = parseInt(parts[2], 10);
+    if (!Number.isNaN(build)) return build;
   }
-  return null
+  return null;
 }
 
 function isPwshAvailable(): boolean {
-  if (process.platform !== "win32") return false
-  const result = spawnSync(["where", "pwsh"], { stdout: "pipe", stderr: "pipe" })
-  return result.exitCode === 0
+  if (process.platform !== 'win32') return false;
+  const result = spawnSync(['where', 'pwsh'], {
+    stdout: 'pipe',
+    stderr: 'pipe',
+  });
+  return result.exitCode === 0;
 }
 
 function escapePowerShellPath(path: string): string {
-  return path.replace(/'/g, "''")
+  return path.replace(/'/g, "''");
 }
 
-type WindowsZipExtractor = "tar" | "pwsh" | "powershell"
+type WindowsZipExtractor = 'tar' | 'pwsh' | 'powershell';
 
 function getWindowsZipExtractor(): WindowsZipExtractor {
-  const buildNumber = getWindowsBuildNumber()
+  const buildNumber = getWindowsBuildNumber();
 
   if (buildNumber !== null && buildNumber >= WINDOWS_BUILD_WITH_TAR) {
-    return "tar"
+    return 'tar';
   }
 
   if (isPwshAvailable()) {
-    return "pwsh"
+    return 'pwsh';
   }
 
-  return "powershell"
+  return 'powershell';
 }
 
-export async function extractZip(archivePath: string, destDir: string): Promise<void> {
-  let proc
+export async function extractZip(
+  archivePath: string,
+  destDir: string,
+): Promise<void> {
+  let proc: ReturnType<typeof spawn>;
 
-  if (process.platform === "win32") {
-    const extractor = getWindowsZipExtractor()
+  if (process.platform === 'win32') {
+    const extractor = getWindowsZipExtractor();
 
     switch (extractor) {
-      case "tar":
-        proc = spawn(["tar", "-xf", archivePath, "-C", destDir], {
-          stdout: "ignore",
-          stderr: "pipe",
-        })
-        break
-      case "pwsh":
+      case 'tar':
+        proc = spawn(['tar', '-xf', archivePath, '-C', destDir], {
+          stdout: 'ignore',
+          stderr: 'pipe',
+        });
+        break;
+      case 'pwsh':
         proc = spawn(
           [
-            "pwsh",
-            "-Command",
+            'pwsh',
+            '-Command',
             `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`,
           ],
           {
-            stdout: "ignore",
-            stderr: "pipe",
-          }
-        )
-        break
-      case "powershell":
+            stdout: 'ignore',
+            stderr: 'pipe',
+          },
+        );
+        break;
       default:
         proc = spawn(
           [
-            "powershell",
-            "-Command",
+            'powershell',
+            '-Command',
             `Expand-Archive -Path '${escapePowerShellPath(archivePath)}' -DestinationPath '${escapePowerShellPath(destDir)}' -Force`,
           ],
           {
-            stdout: "ignore",
-            stderr: "pipe",
-          }
-        )
-        break
+            stdout: 'ignore',
+            stderr: 'pipe',
+          },
+        );
+        break;
     }
   } else {
-    proc = spawn(["unzip", "-o", archivePath, "-d", destDir], {
-      stdout: "ignore",
-      stderr: "pipe",
-    })
+    proc = spawn(['unzip', '-o', archivePath, '-d', destDir], {
+      stdout: 'ignore',
+      stderr: 'pipe',
+    });
   }
 
-  const exitCode = await proc.exited
+  const exitCode = await proc.exited;
 
   if (exitCode !== 0) {
-    const stderr = await new Response(proc.stderr).text()
-    throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
+    const stderr = await new Response(proc.stderr as any).text();
+    throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`);
   }
 }