diff --git a/AGENTS.md b/AGENTS.md index a0c31f2fe..abf615894 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,10 +1,10 @@ # oh-my-opencode — OpenCode Plugin -**Generated:** 2026-02-24 | **Commit:** fcb90d92 | **Branch:** dev +**Generated:** 2026-03-02 | **Commit:** 1c2caa09 | **Branch:** dev ## OVERVIEW -OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1208 TypeScript files, 143k LOC. +OpenCode plugin (npm: `oh-my-opencode`) that extends Claude Code (OpenCode fork) with multi-agent orchestration, 46 lifecycle hooks, 26 tools, skill/command/MCP systems, and Claude Code compatibility. 1243 TypeScript files, 155k LOC. ## STRUCTURE @@ -14,16 +14,16 @@ oh-my-opencode/ │ ├── index.ts # Plugin entry: loadConfig → createManagers → createTools → createHooks → createPluginInterface │ ├── plugin-config.ts # JSONC multi-level config: user → project → defaults (Zod v4) │ ├── agents/ # 11 agents (Sisyphus, Hephaestus, Oracle, Librarian, Explore, Atlas, Prometheus, Metis, Momus, Multimodal-Looker, Sisyphus-Junior) -| `hooks/`                # 46 hooks across 39 directories + 6 standalone files +│ ├── hooks/ # 46 hooks across 45 directories + 11 standalone files │ ├── tools/ # 26 tools across 15 directories │ ├── features/ # 19 feature modules (background-agent, skill-loader, tmux, MCP-OAuth, etc.) -│ ├── shared/ # 100+ utility files in 13 categories -│ ├── config/ # Zod v4 schema system (22+ files) +│ ├── shared/ # 95+ utility files in 13 categories +│ ├── config/ # Zod v4 schema system (24 files) │ ├── cli/ # CLI: install, run, doctor, mcp-oauth (Commander.js) │ ├── mcp/ # 3 built-in remote MCPs (websearch, context7, grep_app) │ ├── plugin/ # 8 OpenCode hook handlers + 46 hook composition │ └── plugin-handlers/ # 6-phase config loading pipeline -├── packages/ # Monorepo: comment-checker, opencode-sdk, 10 platform binaries +├── packages/ # Monorepo: cli-runner, 12 platform binaries └── local-ignore/ # Dev-only test fixtures ``` @@ -123,7 +123,7 @@ bunx oh-my-opencode run # Non-interactive session |----------|---------|---------| | ci.yml | push/PR | Tests (split: mock-heavy isolated + batch), typecheck, build, schema auto-commit | | publish.yml | manual | Version bump, npm publish, platform binaries, GitHub release, merge to dev | -| publish-platform.yml | called | 11 platform binaries via bun compile (darwin/linux/windows) | +| publish-platform.yml | called | 12 platform binaries via bun compile (darwin/linux/windows) | | sisyphus-agent.yml | @mention | AI agent handles issues/PRs | ## NOTES diff --git a/README.md b/README.md index a77de5d39..9d559352b 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ Everything below, every feature, every optimization, you don't need to know it. Even only with following subscriptions, ultrawork will work well (this project is not affiliated, this is just personal recommendation): - [ChatGPT Subscription ($20)](https://chatgpt.com/) -- [Kimi Code Subscription ($0.99) (*only this month)](https://www.kimi.com/membership/pricing?track_id=5cdeca93-66f0-4d35-aabb-b6df8fcea328) +- [Kimi Code Subscription ($0.99) (*only this month)](https://www.kimi.com/kimiplus/sale) - [GLM Coding Plan ($10)](https://z.ai/subscribe) - If you are eligible for pay-per-token, using kimi and gemini models won't cost you that much. diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 16114f11d..5c52ffb04 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -3148,6 +3148,16 @@ }, "additionalProperties": false }, + "custom_agents": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^(?!(?:[bB][uU][iI][lL][dD]|[pP][lL][aA][nN]|[sS][iI][sS][yY][pP][hH][uU][sS]|[hH][eE][pP][hH][aA][eE][sS][tT][uU][sS]|[sS][iI][sS][yY][pP][hH][uU][sS]-[jJ][uU][nN][iI][oO][rR]|[oO][pP][eE][nN][cC][oO][dD][eE]-[bB][uU][iI][lL][dD][eE][rR]|[pP][rR][oO][mM][eE][tT][hH][eE][uU][sS]|[mM][eE][tT][iI][sS]|[mM][oO][mM][uU][sS]|[oO][rR][aA][cC][lL][eE]|[lL][iI][bB][rR][aA][rR][iI][aA][nN]|[eE][xX][pP][lL][oO][rR][eE]|[mM][uU][lL][tT][iI][mM][oO][dD][aA][lL]-[lL][oO][oO][kK][eE][rR]|[aA][tT][lL][aA][sS])$).+" + }, + "additionalProperties": { + "$ref": "#/$defs/agentOverrideConfig" + } + }, "categories": { "type": "object", "propertyNames": { @@ -3685,6 +3695,10 @@ "messageStalenessTimeoutMs": { "type": "number", "minimum": 60000 + }, + "syncPollTimeoutMs": { + "type": "number", + "minimum": 60000 } }, "additionalProperties": false @@ -3837,6 +3851,19 @@ }, "additionalProperties": false }, + "start_work": { + "type": "object", + "properties": { + "auto_commit": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "auto_commit" + ], + "additionalProperties": false + }, "_migrations": { "type": "array", "items": { @@ -3844,5 +3871,226 @@ } } }, - "additionalProperties": false + "additionalProperties": false, + "$defs": { + "agentOverrideConfig": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "fallback_models": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "variant": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "task": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + }, + "additionalProperties": false + }, + "maxTokens": { + "type": "number" + }, + "thinking": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "enabled", + "disabled" + ] + }, + "budgetTokens": { + "type": "number" + } + }, + "required": [ + "type" + ], + "additionalProperties": false + }, + "reasoningEffort": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "xhigh" + ] + }, + "textVerbosity": { + "type": "string", + "enum": [ + "low", + "medium", + "high" + ] + }, + "providerOptions": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "ultrawork": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + }, + "compaction": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "variant": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } } \ No newline at end of file diff --git a/bun.lock b/bun.lock index 1c5d84e94..c67b9095a 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@clack/prompts": "^0.11.0", - "@code-yeongyu/comment-checker": "^0.6.1", + "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opencode-ai/plugin": "^1.1.19", "@opencode-ai/sdk": "^1.1.19", @@ -29,13 +29,17 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.8.5", - "oh-my-opencode-darwin-x64": "3.8.5", - "oh-my-opencode-linux-arm64": "3.8.5", - "oh-my-opencode-linux-arm64-musl": "3.8.5", - "oh-my-opencode-linux-x64": "3.8.5", - "oh-my-opencode-linux-x64-musl": "3.8.5", - "oh-my-opencode-windows-x64": "3.8.5", + "oh-my-opencode-darwin-arm64": "3.10.0", + "oh-my-opencode-darwin-x64": "3.10.0", + "oh-my-opencode-darwin-x64-baseline": "3.10.0", + "oh-my-opencode-linux-arm64": "3.10.0", + "oh-my-opencode-linux-arm64-musl": "3.10.0", + "oh-my-opencode-linux-x64": "3.10.0", + "oh-my-opencode-linux-x64-baseline": "3.10.0", + "oh-my-opencode-linux-x64-musl": "3.10.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.10.0", + "oh-my-opencode-windows-x64": "3.10.0", + "oh-my-opencode-windows-x64-baseline": "3.10.0", }, }, }, @@ -85,7 +89,7 @@ "@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="], - "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="], + "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.7.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-AOic1jPHY3CpNraOuO87YZHO3uRzm9eLd0wyYYN89/76Ugk2TfdUYJ6El/Oe8fzOnHKiOF0IfBeWRo0IUjrHHg=="], "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], @@ -231,19 +235,27 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.10.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KQ1Nva4eU03WIaQI8BiEgizYJAeddUIaC8dmks0Ug/2EkH6VyNj41+shI58HFGN9Jlg9Fd6MxpOW92S3JUHjOw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.10.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-PydZ6wKyLZzikSZA3Q89zKZwFyg0Ouqd/S6zDsf1zzpUWT1t5EcpBtYFwuscD7L4hdkIEFm8wxnnBkz5i6BEiA=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="], + "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.10.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-yOaVd0E1qspT2xP/BMJaJ/rpFTwkOh9U/SAk6uOuxHld6dZGI9e2Oq8F3pSD16xHnnpaz4VzadtT6HkvPdtBYg=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.10.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-pLzcPMuzBb1tpVgqMilv7QdsE2xTMLCWT3b807mzjt0302fZTfm6emwymCG25RamHdq7+mI2B0rN7hjvbymFog=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.10.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ca61zr+X8q0ipO2x72qU+4R6Dsr168OM9aXI6xDHbrr0l3XZlRO8xuwQidch1vE5QRv2/IJT10KjAFInCERDug=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m0Ys8Vnl8jUNRE5/aIseNOF1H57/W77xh3vkyBVfnjzHwQdEUWZz3IdoHaEWIFgIP2+fsNXRHqpx7Pbtuhxo6Q=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="], + "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-a6OhfqMXhOTq1On8YHRRlVsNtMx84kgNAnStk/sY1Dw0kXU68QK4tWXVF+wNdiRG3egeM2SvjhJ5RhWlr3CCNQ=="], + + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-lZkoEWwmrlVoZKewHNslUmQ2D6eWi1YqsoZMTd3qRj8V4XI6TDZHxg86hw4oxZ/EnKO4un+r83tb09JAAb1nNQ=="], + + "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.10.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UqArUpatMuen8+hZhMSbScaSmJlcwkEtf/IzDN1iYO0CttvhyYMUmm3el/1gWTAcaGNDFNkGmTli5WNYhnm2lA=="], + + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.10.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-BivOu1+Yty9N6VSmNzmxROZqjQKu3ImWjooKZDfczvYLDQmZV104QcOKV6bmdOCpHrqQ7cvdbygmeiJeRoYShg=="], + + "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.10.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-BBv+dNPuh9LEuqXUJLXNsvi3vL30zS1qcJuzlq/s8rYHry+VvEVXCRcMm5Vo0CVna8bUZf5U8MDkGDHOAiTeEw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/docs/guide/overview.md b/docs/guide/overview.md index 21f337324..0aaca2c2a 100644 --- a/docs/guide/overview.md +++ b/docs/guide/overview.md @@ -68,6 +68,8 @@ User Request When Sisyphus delegates to a subagent, it doesn't pick a model name. It picks a **category** — `visual-engineering`, `ultrabrain`, `quick`, `deep`. The category automatically maps to the right model. You touch nothing. +Custom agents are also first-class in this flow. When custom agents are loaded, planning context includes them, so the orchestrator can choose them proactively when appropriate, and you can call them directly on demand via `task(subagent_type="your-agent")`. + For a deep dive into how agents collaborate, see the [Orchestration System Guide](./orchestration.md). --- diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index c01865a5f..a71038848 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -11,6 +11,7 @@ Complete reference for `oh-my-opencode.jsonc` configuration. This document cover - [Quick Start Example](#quick-start-example) - [Core Concepts](#core-concepts) - [Agents](#agents) + - [Custom Agents (`custom_agents`)](#custom-agents-custom_agents) - [Categories](#categories) - [Model Resolution](#model-resolution) - [Task System](#task-system) @@ -130,6 +131,8 @@ Here's a practical starting configuration: Override built-in agent settings. Available agents: `sisyphus`, `hephaestus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`. +`agents` is intentionally strict and only accepts built-in agent keys. Use `custom_agents` for user-defined agents. + ```json { "agents": { @@ -200,6 +203,64 @@ Control what tools an agent can use: | `doom_loop` | `ask` / `allow` / `deny` | | `external_directory` | `ask` / `allow` / `deny` | +### Custom Agents (`custom_agents`) + +Use `custom_agents` to configure user-defined agents without mixing them into built-in `agents` overrides. + +What this gives you: + +- **Clean separation**: built-ins stay in `agents`, user-defined entries stay in `custom_agents`. +- **Safer config**: keys in `custom_agents` cannot reuse built-in names. +- **First-class orchestration**: loaded custom agents are visible to planner/orchestrator context, so they can be selected proactively during planning and invoked on demand via `task(subagent_type=...)`. +- **Full model controls** for custom agents: `model`, `variant`, `temperature`, `top_p`, `reasoningEffort`, `thinking`, etc. + +Important behavior: + +- `custom_agents` **overrides existing custom agents** loaded at runtime (for example from Claude Code/OpenCode agent sources). +- `custom_agents` does **not** create an agent from thin air by itself; the target custom agent must be present in runtime-loaded agent configs. + +Example: + +```jsonc +{ + "custom_agents": { + "translator": { + "model": "openai/gpt-5.3-codex", + "variant": "high", + "temperature": 0.2, + "prompt_append": "Keep locale placeholders and ICU tokens exactly unchanged." + }, + "reviewer-fast": { + "model": "anthropic/claude-haiku-4-5", + "temperature": 0, + "thinking": { + "type": "enabled", + "budgetTokens": 20000 + } + } + } +} +``` + +On-demand invocation through task delegation: + +```ts +task( + { + subagent_type: "translator", + load_skills: [], + description: "Translate release notes", + prompt: "Translate docs/CHANGELOG.md into Korean while preserving markdown structure.", + run_in_background: false, + }, +) +``` + +Migration note: + +- If you previously put custom entries under `agents.*`, move them to `custom_agents.*`. +- Unknown built-in keys under `agents` are reported with migration hints. + ### Categories Domain-specific model delegation used by the `task()` tool. When Sisyphus delegates work, it picks a category, not a model name. @@ -573,13 +634,13 @@ Define `fallback_models` per agent or category: ### Hashline Edit -Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Enabled by default. +Replaces the built-in `Edit` tool with a hash-anchored version using `LINE#ID` references to prevent stale-line edits. Disabled by default. ```json -{ "hashline_edit": false } +{ "hashline_edit": true } ``` -When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Disable them individually via `disabled_hooks`. +When enabled, two companion hooks are active: `hashline-read-enhancer` (annotates Read output) and `hashline-edit-diff-enhancer` (shows diffs). Opt-in by setting `hashline_edit: true`. Disable the companion hooks individually via `disabled_hooks` if needed. ### Experimental diff --git a/package.json b/package.json index 012e6dcc4..bd1a41148 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.9.0", + "version": "3.10.0", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -54,7 +54,7 @@ "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@clack/prompts": "^0.11.0", - "@code-yeongyu/comment-checker": "^0.6.1", + "@code-yeongyu/comment-checker": "^0.7.0", "@modelcontextprotocol/sdk": "^1.25.2", "@opencode-ai/plugin": "^1.1.19", "@opencode-ai/sdk": "^1.1.19", @@ -75,17 +75,17 @@ "typescript": "^5.7.3" }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.9.0", - "oh-my-opencode-darwin-x64": "3.9.0", - "oh-my-opencode-darwin-x64-baseline": "3.9.0", - "oh-my-opencode-linux-arm64": "3.9.0", - "oh-my-opencode-linux-arm64-musl": "3.9.0", - "oh-my-opencode-linux-x64": "3.9.0", - "oh-my-opencode-linux-x64-baseline": "3.9.0", - "oh-my-opencode-linux-x64-musl": "3.9.0", - "oh-my-opencode-linux-x64-musl-baseline": "3.9.0", - "oh-my-opencode-windows-x64": "3.9.0", - "oh-my-opencode-windows-x64-baseline": "3.9.0" + "oh-my-opencode-darwin-arm64": "3.10.0", + "oh-my-opencode-darwin-x64": "3.10.0", + "oh-my-opencode-darwin-x64-baseline": "3.10.0", + "oh-my-opencode-linux-arm64": "3.10.0", + "oh-my-opencode-linux-arm64-musl": "3.10.0", + "oh-my-opencode-linux-x64": "3.10.0", + "oh-my-opencode-linux-x64-baseline": "3.10.0", + "oh-my-opencode-linux-x64-musl": "3.10.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.10.0", + "oh-my-opencode-windows-x64": "3.10.0", + "oh-my-opencode-windows-x64-baseline": "3.10.0" }, "trustedDependencies": [ "@ast-grep/cli", diff --git a/packages/darwin-arm64/package.json b/packages/darwin-arm64/package.json index af9f8d5ac..c6e5946a2 100644 --- a/packages/darwin-arm64/package.json +++ b/packages/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-arm64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64-baseline/package.json b/packages/darwin-x64-baseline/package.json index 2cef9b7f3..3c8cab0f5 100644 --- a/packages/darwin-x64-baseline/package.json +++ b/packages/darwin-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/darwin-x64/package.json b/packages/darwin-x64/package.json index 42f7e0456..923c58288 100644 --- a/packages/darwin-x64/package.json +++ b/packages/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-darwin-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64-musl/package.json b/packages/linux-arm64-musl/package.json index 4de8b4689..874635eb0 100644 --- a/packages/linux-arm64-musl/package.json +++ b/packages/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64-musl", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-arm64/package.json b/packages/linux-arm64/package.json index 66d0d1267..a864c9eb6 100644 --- a/packages/linux-arm64/package.json +++ b/packages/linux-arm64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-arm64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-baseline/package.json b/packages/linux-x64-baseline/package.json index 5fd5f2ad2..200b40e04 100644 --- a/packages/linux-x64-baseline/package.json +++ b/packages/linux-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl-baseline/package.json b/packages/linux-x64-musl-baseline/package.json index 679729f73..b55ac1f24 100644 --- a/packages/linux-x64-musl-baseline/package.json +++ b/packages/linux-x64-musl-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/linux-x64-musl/package.json b/packages/linux-x64-musl/package.json index 376329a3c..eb6a5a136 100644 --- a/packages/linux-x64-musl/package.json +++ b/packages/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64-musl", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "license": "MIT", "repository": { diff --git a/packages/linux-x64/package.json b/packages/linux-x64/package.json index 889d92324..a0ab8a33e 100644 --- a/packages/linux-x64/package.json +++ b/packages/linux-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-linux-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (linux-x64)", "license": "MIT", "repository": { diff --git a/packages/windows-x64-baseline/package.json b/packages/windows-x64-baseline/package.json index 2510abfbc..066dc0bb2 100644 --- a/packages/windows-x64-baseline/package.json +++ b/packages/windows-x64-baseline/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64-baseline", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)", "license": "MIT", "repository": { diff --git a/packages/windows-x64/package.json b/packages/windows-x64/package.json index 8909dd7f1..7afaf5b47 100644 --- a/packages/windows-x64/package.json +++ b/packages/windows-x64/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode-windows-x64", - "version": "3.9.0", + "version": "3.10.0", "description": "Platform-specific binary for oh-my-opencode (windows-x64)", "license": "MIT", "repository": { diff --git a/script/build-schema-document.ts b/script/build-schema-document.ts index f93302fce..2ede3e2b7 100644 --- a/script/build-schema-document.ts +++ b/script/build-schema-document.ts @@ -1,17 +1,53 @@ import * as z from "zod" import { OhMyOpenCodeConfigSchema } from "../src/config/schema" +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +function dedupeCustomAgentOverrideSchema(schema: Record): Record { + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const builtInAgentProps = asRecord(agentsSchema?.properties) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + + if (!builtInAgentProps || !customAgentsSchema || !customAdditionalProperties) { + return schema + } + + const referenceAgentSchema = asRecord( + builtInAgentProps.build + ?? builtInAgentProps.oracle + ?? builtInAgentProps.explore, + ) + + if (!referenceAgentSchema) { + return schema + } + + const defs = asRecord(schema.$defs) ?? {} + defs.agentOverrideConfig = referenceAgentSchema + schema.$defs = defs + + customAgentsSchema.additionalProperties = { $ref: "#/$defs/agentOverrideConfig" } + + return schema +} + export function createOhMyOpenCodeJsonSchema(): Record { const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { target: "draft-7", unrepresentable: "any", }) - return { + const schema = { $schema: "http://json-schema.org/draft-07/schema#", $id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json", title: "Oh My OpenCode Configuration", description: "Configuration schema for oh-my-opencode plugin", ...jsonSchema, } + + return dedupeCustomAgentOverrideSchema(schema) } diff --git a/signatures/cla.json b/signatures/cla.json index 0860b761e..761512612 100644 --- a/signatures/cla.json +++ b/signatures/cla.json @@ -1799,6 +1799,46 @@ "created_at": "2026-02-27T10:53:03Z", "repoId": 1108837393, "pullRequestNo": 2184 + }, + { + "name": "renanale", + "id": 37278838, + "comment_id": 3975562407, + "created_at": "2026-02-27T22:38:18Z", + "repoId": 1108837393, + "pullRequestNo": 2201 + }, + { + "name": "laciferin2024", + "id": 170102251, + "comment_id": 3978786169, + "created_at": "2026-03-01T01:16:25Z", + "repoId": 1108837393, + "pullRequestNo": 2222 + }, + { + "name": "DEAN-Cherry", + "id": 76607677, + "comment_id": 3979468463, + "created_at": "2026-03-01T08:13:43Z", + "repoId": 1108837393, + "pullRequestNo": 2227 + }, + { + "name": "Chocothin", + "id": 99174213, + "comment_id": 3980002001, + "created_at": "2026-03-01T13:52:10Z", + "repoId": 1108837393, + "pullRequestNo": 2230 + }, + { + "name": "mathew-cf", + "id": 68972715, + "comment_id": 3980951159, + "created_at": "2026-03-01T20:19:31Z", + "repoId": 1108837393, + "pullRequestNo": 2233 } ] } \ No newline at end of file diff --git a/src/AGENTS.md b/src/AGENTS.md index 197af269c..b224e8be5 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -1,6 +1,6 @@ # src/ — Plugin Source -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/agents/AGENTS.md b/src/agents/AGENTS.md index 289a0fca7..5ce16f271 100644 --- a/src/agents/AGENTS.md +++ b/src/agents/AGENTS.md @@ -1,6 +1,6 @@ # src/agents/ — 11 Agent Definitions -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW @@ -10,16 +10,16 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each | Agent | Model | Temp | Mode | Fallback Chain | Purpose | |-------|-------|------|------|----------------|---------| -| **Sisyphus** | claude-opus-4-6 | 0.1 | primary | kimi-k2.5 → glm-4.7 → gemini-3-pro | Main orchestrator, plans + delegates | -| **Hephaestus** | gpt-5.3-codex | 0.1 | primary | NONE (required) | Autonomous deep worker | -| **Oracle** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Read-only consultation | -| **Librarian** | glm-4.7 | 0.1 | subagent | big-pickle → claude-sonnet-4-6 | External docs/code search | -| **Explore** | grok-code-fast-1 | 0.1 | subagent | claude-haiku-4-5 → gpt-5-nano | Contextual grep | -| **Multimodal-Looker** | gemini-3-flash | 0.1 | subagent | gpt-5.2 → glm-4.6v → ... (6 deep) | PDF/image analysis | -| **Metis** | claude-opus-4-6 | **0.3** | subagent | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Pre-planning consultant | -| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3-pro | Plan reviewer | -| **Atlas** | claude-sonnet-4-6 | 0.1 | primary | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Todo-list orchestrator | -| **Prometheus** | claude-opus-4-6 | 0.1 | — | kimi-k2.5 → gpt-5.2 → gemini-3-pro | Strategic planner (internal) | +| **Sisyphus** | claude-opus-4-6 | 0.1 | all | kimi-k2.5 → glm-5 → big-pickle | Main orchestrator, plans + delegates | +| **Hephaestus** | gpt-5.3-codex | 0.1 | all | gpt-5.2 (copilot) | Autonomous deep worker | +| **Oracle** | gpt-5.2 | 0.1 | subagent | gemini-3.1-pro → claude-opus-4-6 | Read-only consultation | +| **Librarian** | kimi-k2.5 | 0.1 | subagent | gemini-3-flash → gpt-5.2 → glm-4.6v | External docs/code search | +| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.5 → claude-haiku-4-5 → gpt-5-nano | Contextual grep | +| **Multimodal-Looker** | gemini-3-flash | 0.1 | subagent | minimax-m2.5 → big-pickle | PDF/image analysis | +| **Metis** | claude-opus-4-6 | **0.3** | subagent | gpt-5.2 → kimi-k2.5 → gemini-3.1-pro | Pre-planning consultant | +| **Momus** | gpt-5.2 | 0.1 | subagent | claude-opus-4-6 → gemini-3.1-pro | Plan reviewer | +| **Atlas** | kimi-k2.5 | 0.1 | primary | claude-sonnet-4-6 → gpt-5.2 | Todo-list orchestrator | +| **Prometheus** | claude-opus-4-6 | 0.1 | — | kimi-k2.5 → gpt-5.2 → gemini-3.1-pro | Strategic planner (internal) | | **Sisyphus-Junior** | claude-sonnet-4-6 | 0.1 | all | user-configurable | Category-spawned executor | ## TOOL RESTRICTIONS diff --git a/src/agents/dynamic-agent-prompt-builder.test.ts b/src/agents/dynamic-agent-prompt-builder.test.ts index f105542b7..8572e72eb 100644 --- a/src/agents/dynamic-agent-prompt-builder.test.ts +++ b/src/agents/dynamic-agent-prompt-builder.test.ts @@ -4,6 +4,8 @@ import { describe, it, expect } from "bun:test" import { buildCategorySkillsDelegationGuide, buildUltraworkSection, + buildDeepParallelSection, + buildNonClaudePlannerSection, type AvailableSkill, type AvailableCategory, type AvailableAgent, @@ -172,4 +174,86 @@ describe("buildUltraworkSection", () => { }) }) +describe("buildDeepParallelSection", () => { + const deepCategory: AvailableCategory = { name: "deep", description: "Autonomous problem-solving" } + const otherCategory: AvailableCategory = { name: "quick", description: "Trivial tasks" } + + it("#given non-Claude model with deep category #when building #then returns parallel delegation section", () => { + //#given + const model = "google/gemini-3-pro" + const categories = [deepCategory, otherCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toContain("Deep Parallel Delegation") + expect(result).toContain("EVERY independent unit") + expect(result).toContain("run_in_background=true") + expect(result).toContain("4 independent units") + }) + + it("#given Claude model #when building #then returns empty", () => { + //#given + const model = "anthropic/claude-opus-4-6" + const categories = [deepCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toBe("") + }) + + it("#given non-Claude model without deep category #when building #then returns empty", () => { + //#given + const model = "openai/gpt-5.2" + const categories = [otherCategory] + + //#when + const result = buildDeepParallelSection(model, categories) + + //#then + expect(result).toBe("") + }) +}) + +describe("buildNonClaudePlannerSection", () => { + it("#given non-Claude model #when building #then returns plan agent section", () => { + //#given + const model = "google/gemini-3-pro" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toContain("Plan Agent") + expect(result).toContain("session_id") + expect(result).toContain("Multi-step") + }) + + it("#given Claude model #when building #then returns empty", () => { + //#given + const model = "anthropic/claude-sonnet-4-6" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toBe("") + }) + + it("#given GPT model #when building #then returns plan agent section", () => { + //#given + const model = "openai/gpt-5.2" + + //#when + const result = buildNonClaudePlannerSection(model) + + //#then + expect(result).toContain("Plan Agent") + expect(result).not.toBe("") + }) +}) + diff --git a/src/agents/dynamic-agent-prompt-builder.ts b/src/agents/dynamic-agent-prompt-builder.ts index 5c0ad5784..a07e9cb61 100644 --- a/src/agents/dynamic-agent-prompt-builder.ts +++ b/src/agents/dynamic-agent-prompt-builder.ts @@ -277,12 +277,11 @@ Briefly announce "Consulting Oracle for [reason]" before invocation. ### Oracle Background Task Policy: -**You MUST collect Oracle results before your final answer. No exceptions.** +**Collect Oracle results before your final answer. No exceptions.** -- Oracle may take several minutes. This is normal and expected. -- When Oracle is running and you finish your own exploration/analysis, your next action is \`background_output(task_id="...")\` on Oracle — NOT delivering a final answer. -- Oracle catches blind spots you cannot see — its value is HIGHEST when you think you don't need it. -- **NEVER** cancel Oracle. **NEVER** use \`background_cancel(all=true)\` when Oracle is running. Cancel disposable tasks (explore, librarian) individually by taskId instead. +- Oracle takes minutes. When done with your own work: **end your response** — wait for the \`\`. +- Do NOT poll \`background_output\` on a running Oracle. The notification will come. +- Never cancel Oracle. ` } @@ -292,8 +291,8 @@ export function buildHardBlocksSection(): string { "- Commit without explicit request — **Never**", "- Speculate about unread code — **Never**", "- Leave code in broken state after failures — **Never**", - "- `background_cancel(all=true)` when Oracle is running — **Never.** Cancel tasks individually by taskId.", - "- Delivering final answer before collecting Oracle result — **Never.** Always `background_output` Oracle first.", + "- `background_cancel(all=true)` — **Never.** Always cancel individually by taskId.", + "- Delivering final answer before collecting Oracle result — **Never.**", ] return `## Hard Blocks (NEVER violate) @@ -308,8 +307,8 @@ export function buildAntiPatternsSection(): string { "- **Testing**: Deleting failing tests to \"pass\"", "- **Search**: Firing agents for single-line typos or obvious syntax errors", "- **Debugging**: Shotgun debugging, random changes", - "- **Background Tasks**: `background_cancel(all=true)` — always cancel individually by taskId", - "- **Oracle**: Skipping Oracle results when Oracle was launched — ALWAYS collect via `background_output`", + "- **Background Tasks**: Polling `background_output` on running tasks — end response and wait for notification", + "- **Oracle**: Delivering answer without collecting Oracle results", ] return `## Anti-Patterns (BLOCKING violations) @@ -334,6 +333,22 @@ When you need to call a tool: Your tool calls are processed automatically. Just invoke the tool - do not format the call yourself.` } +export function buildNonClaudePlannerSection(model: string): string { + const isNonClaude = !model.toLowerCase().includes('claude') + if (!isNonClaude) return "" + + return `### Plan Agent Dependency (Non-Claude) + +Multi-step task? **ALWAYS consult Plan Agent first.** Do NOT start implementation without a plan. + +- Single-file fix or trivial change → proceed directly +- Anything else (2+ steps, unclear scope, architecture) → \`task(subagent_type="plan", ...)\` FIRST +- Use \`session_id\` to resume the same Plan Agent — ask follow-up questions aggressively +- If ANY part of the task is ambiguous, ask Plan Agent before guessing + +Plan Agent returns a structured work breakdown with parallel execution opportunities. Follow it.` +} + export function buildDeepParallelSection(model: string, categories: AvailableCategory[]): string { const isNonClaude = !model.toLowerCase().includes('claude') const hasDeepCategory = categories.some(c => c.name === 'deep') @@ -342,12 +357,13 @@ export function buildDeepParallelSection(model: string, categories: AvailableCat return `### Deep Parallel Delegation -For implementation tasks, actively decompose and delegate to \`deep\` category agents in parallel. +Delegate EVERY independent unit to a \`deep\` agent in parallel (\`run_in_background=true\`). +If a task decomposes into 4 independent units, spawn 4 agents simultaneously — not 1 at a time. -1. Break the implementation into independent work units -2. Maximize parallel deep agents — spawn one per independent unit (\`run_in_background=true\`) -3. Give each agent a GOAL, not step-by-step instructions — deep agents explore and solve autonomously -4. Collect results, integrate, verify coherence` +1. Decompose the implementation into independent work units +2. Assign one \`deep\` agent per unit — all via \`run_in_background=true\` +3. Give each agent a clear GOAL with success criteria, not step-by-step instructions +4. Collect all results, integrate, verify coherence across units` } export function buildUltraworkSection( diff --git a/src/agents/sisyphus-gemini-overlays.ts b/src/agents/sisyphus-gemini-overlays.ts index e1e239332..6860e3eaa 100644 --- a/src/agents/sisyphus-gemini-overlays.ts +++ b/src/agents/sisyphus-gemini-overlays.ts @@ -39,6 +39,136 @@ Then ACTUALLY CALL those tools using the JSON tool schema. Produce the tool_use `; } +export function buildGeminiToolGuide(): string { + return ` +## Tool Usage Guide — WHEN and HOW to Call Each Tool + +You have access to tools via function calling. This guide defines WHEN to call each one. +**Violating these patterns = failed response.** + +### Reading & Search (ALWAYS parallelizable — call multiple simultaneously) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Read\` | Before making ANY claim about file contents. Before editing any file. | � Yes — read multiple files at once | +| \`Grep\` | Finding patterns, imports, usages across codebase. BEFORE claiming "X is used in Y". | ✅ Yes — run multiple greps at once | +| \`Glob\` | Finding files by name/extension pattern. BEFORE claiming "file X exists". | ✅ Yes — run multiple globs at once | +| \`AstGrepSearch\` | Finding code patterns with AST awareness (structural matches). | ✅ Yes | + +### Code Intelligence (parallelizable on different files) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`LspDiagnostics\` | **AFTER EVERY edit.** BEFORE claiming task is done. MANDATORY. | ✅ Yes — different files | +| \`LspGotoDefinition\` | Finding where a symbol is defined. | ✅ Yes | +| \`LspFindReferences\` | Finding all usages of a symbol across workspace. | ✅ Yes | +| \`LspSymbols\` | Getting file outline or searching workspace symbols. | ✅ Yes | + +### Editing (SEQUENTIAL — must Read first) + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Edit\` | Modifying existing files. MUST Read file first to get LINE#ID anchors. | ❌ After Read | +| \`Write\` | Creating NEW files only. Or full file overwrite. | ❌ Sequential | + +### Execution & Delegation + +| Tool | When to Call | Parallel? | +|---|---|---| +| \`Bash\` | Running tests, builds, git commands. | ❌ Usually sequential | +| \`Task\` | ANY non-trivial implementation. Research via explore/librarian. | ✅ Fire multiple in background | + +### Correct Sequences (MANDATORY — follow these exactly): + +1. **Answer about code**: Read → (analyze) → Answer +2. **Edit code**: Read → Edit → LspDiagnostics → Report +3. **Find something**: Grep/Glob (parallel) → Read results → Report +4. **Implement feature**: Task(delegate) → Verify results → Report +5. **Debug**: Read error → Read file → Grep related → Fix → LspDiagnostics + +### PARALLEL RULES: + +- **Independent reads/searches**: ALWAYS call simultaneously in ONE response +- **Dependent operations**: Call sequentially (Edit AFTER Read, LspDiagnostics AFTER Edit) +- **Background agents**: ALWAYS \`run_in_background=true\`, continue working +`; +} + +export function buildGeminiToolCallExamples(): string { + return ` +## Correct Tool Calling Patterns — Follow These Examples + +### Example 1: User asks about code → Read FIRST, then answer +**User**: "How does the auth middleware work?" +**CORRECT**: +\`\`\` +→ Call Read(filePath="/src/middleware/auth.ts") +→ Call Read(filePath="/src/config/auth.ts") // parallel with above +→ (After reading) Answer based on ACTUAL file contents +\`\`\` +**WRONG**: +\`\`\` +→ "The auth middleware likely validates JWT tokens by..." ← HALLUCINATION. You didn't read the file. +\`\`\` + +### Example 2: User asks to edit code → Read, Edit, Verify +**User**: "Fix the type error in user.ts" +**CORRECT**: +\`\`\` +→ Call Read(filePath="/src/models/user.ts") +→ Call LspDiagnostics(filePath="/src/models/user.ts") // parallel with Read +→ (After reading) Call Edit with LINE#ID anchors +→ Call LspDiagnostics(filePath="/src/models/user.ts") // verify fix +→ Report: "Fixed. Diagnostics clean." +\`\`\` +**WRONG**: +\`\`\` +→ Call Edit without reading first ← No LINE#ID anchors = WILL FAIL +→ Skip LspDiagnostics after edit ← UNVERIFIED +\`\`\` + +### Example 3: User asks to find something → Search in parallel +**User**: "Where is the database connection configured?" +**CORRECT**: +\`\`\` +→ Call Grep(pattern="database|connection|pool", path="/src") // fires simultaneously +→ Call Glob(pattern="**/*database*") // fires simultaneously +→ Call Glob(pattern="**/*db*") // fires simultaneously +→ (After results) Read the most relevant files +→ Report findings with file paths +\`\`\` + +### Example 4: User asks to implement a feature → DELEGATE +**User**: "Add a new /health endpoint to the API" +**CORRECT**: +\`\`\` +→ Call Task(category="quick", load_skills=["typescript-programmer"], prompt="...") +→ (After agent completes) Read changed files to verify +→ Call LspDiagnostics on changed files +→ Report +\`\`\` +**WRONG**: +\`\`\` +→ Write the code yourself ← YOU ARE AN ORCHESTRATOR, NOT AN IMPLEMENTER +\`\`\` + +### Example 5: Investigation ≠ Implementation +**User**: "Look into why the tests are failing" +**CORRECT**: +\`\`\` +→ Call Bash(command="npm test") // see actual failures +→ Call Read on failing test files +→ Call Read on source files under test +→ Report: "Tests fail because X. Root cause: Y. Proposed fix: Z." +→ STOP — wait for user to say "fix it" +\`\`\` +**WRONG**: +\`\`\` +→ Start editing source files immediately ← "look into" ≠ "fix" +\`\`\` +`; +} + export function buildGeminiDelegationOverride(): string { return ` ## DELEGATION IS MANDATORY — YOU ARE NOT AN IMPLEMENTER diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 06debf111..042cec1a1 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -6,6 +6,8 @@ import { buildGeminiDelegationOverride, buildGeminiVerificationOverride, buildGeminiIntentGateEnforcement, + buildGeminiToolGuide, + buildGeminiToolCallExamples, } from "./sisyphus-gemini-overlays"; const MODE: AgentMode = "all"; @@ -32,6 +34,7 @@ import { buildHardBlocksSection, buildAntiPatternsSection, buildDeepParallelSection, + buildNonClaudePlannerSection, categorizeTools, } from "./dynamic-agent-prompt-builder"; @@ -170,6 +173,7 @@ function buildDynamicSisyphusPrompt( const hardBlocks = buildHardBlocksSection(); const antiPatterns = buildAntiPatternsSection(); const deepParallelSection = buildDeepParallelSection(model, availableCategories); + const nonClaudePlannerSection = buildNonClaudePlannerSection(model); const taskManagementSection = buildTaskManagementSection(useTaskSystem); const todoHookNote = useTaskSystem ? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])" @@ -329,7 +333,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio // Reference Grep (external) task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.") task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.") -// Continue working immediately. Collect with background_output when needed. +// Continue working immediately. System notifies on completion — collect with background_output then. // WRONG: Sequential or blocking result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian @@ -337,10 +341,10 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp ### Background Result Collection: 1. Launch parallel agents \u2192 receive task_ids -2. Continue immediate work (explore, librarian results) -3. When results needed: \`background_output(task_id="...")\` -4. **If Oracle is running**: STOP all other output. Follow Oracle Completion Protocol in . -5. Cleanup: Cancel disposable tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. Never use \`background_cancel(all=true)\`. +2. Continue immediate work +3. System sends \`\` on each task completion — then call \`background_output(task_id="...")\` +4. Need results not yet ready? **End your response.** The notification will trigger your next turn. +5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\` ### Search Stop Conditions @@ -364,6 +368,8 @@ STOP searching when: ${categorySkillsGuide} +${nonClaudePlannerSection} + ${deepParallelSection} ${delegationTable} @@ -477,9 +483,8 @@ If verification fails: 3. Report: "Done. Note: found N pre-existing lint errors unrelated to my changes." ### Before Delivering Final Answer: -- **If Oracle is running**: STOP. Follow Oracle Completion Protocol in . Do NOT deliver any answer. -- Cancel disposable background tasks (explore, librarian) individually via \`background_cancel(taskId="...")\`. -- **Never use \`background_cancel(all=true)\`.** +- If Oracle is running: **end your response** and wait for the completion notification first. +- Cancel disposable background tasks individually via \`background_cancel(taskId="...")\`. ${oracleSection} @@ -565,12 +570,25 @@ export function createSisyphusAgent( : buildDynamicSisyphusPrompt(model, [], tools, skills, categories, useTaskSystem); if (isGeminiModel(model)) { + // 1. Intent gate + tool mandate — early in prompt (after intent verbalization) prompt = prompt.replace( "", `\n\n${buildGeminiIntentGateEnforcement()}\n\n${buildGeminiToolMandate()}` ); - prompt += "\n" + buildGeminiDelegationOverride(); - prompt += "\n" + buildGeminiVerificationOverride(); + + // 2. Tool guide + examples — after tool_usage_rules (where tools are discussed) + prompt = prompt.replace( + "", + `\n\n${buildGeminiToolGuide()}\n\n${buildGeminiToolCallExamples()}` + ); + + // 3. Delegation + verification overrides — before Constraints (NOT at prompt end) + // Gemini suffers from lost-in-the-middle: content at prompt end gets weaker attention. + // Placing these before ensures they're in a high-attention zone. + prompt = prompt.replace( + "", + `${buildGeminiDelegationOverride()}\n\n${buildGeminiVerificationOverride()}\n\n` + ); } const permission = { diff --git a/src/agents/utils.test.ts b/src/agents/utils.test.ts index 129329afa..f4ecb5040 100644 --- a/src/agents/utils.test.ts +++ b/src/agents/utils.test.ts @@ -242,14 +242,28 @@ describe("createBuiltinAgents with model overrides", () => { test("createBuiltinAgents excludes disabled skills from availableSkills", async () => { // #given const disabledSkills = new Set(["playwright"]) + const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) + const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( + new Set([ + "anthropic/claude-opus-4-6", + "opencode/kimi-k2.5-free", + "zai-coding-plan/glm-5", + "opencode/big-pickle", + ]) + ) - // #when - const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) + try { + // #when + const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills) - // #then - expect(agents.sisyphus.prompt).not.toContain("playwright") - expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") - expect(agents.sisyphus.prompt).toContain("git-master") + // #then + expect(agents.sisyphus.prompt).not.toContain("playwright") + expect(agents.sisyphus.prompt).toContain("frontend-ui-ux") + expect(agents.sisyphus.prompt).toContain("git-master") + } finally { + cacheSpy.mockRestore() + fetchSpy.mockRestore() + } }) test("includes custom agents in orchestrator prompts when provided via config", async () => { diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 1cdb7fe5d..01abe527a 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/ — CLI: install, run, doctor, mcp-oauth -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/cli/config-manager/AGENTS.md b/src/cli/config-manager/AGENTS.md index 45e3d2d14..37f8c80b6 100644 --- a/src/cli/config-manager/AGENTS.md +++ b/src/cli/config-manager/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/config-manager/ — CLI Installation Utilities -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/cli/config-manager/config-context.ts b/src/cli/config-manager/config-context.ts index 67448f29a..78eb88d77 100644 --- a/src/cli/config-manager/config-context.ts +++ b/src/cli/config-manager/config-context.ts @@ -19,9 +19,6 @@ export function initConfigContext(binary: OpenCodeBinaryType, version: string | export function getConfigContext(): ConfigContext { if (!configContext) { - if (process.env.NODE_ENV !== "production") { - console.warn("[config-context] getConfigContext() called before initConfigContext(); defaulting to CLI paths.") - } const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null }) configContext = { binary: "opencode", version: null, paths } } diff --git a/src/cli/doctor/checks/system.ts b/src/cli/doctor/checks/system.ts index 05d32d681..01fa162d1 100644 --- a/src/cli/doctor/checks/system.ts +++ b/src/cli/doctor/checks/system.ts @@ -93,7 +93,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin version mismatch", description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`, - fix: "Reinstall plugin dependencies in OpenCode cache", + fix: `Reinstall: cd ${loadedInfo.cacheDir} && bun install`, severity: "warning", affects: ["plugin loading"], }) @@ -107,7 +107,7 @@ export async function checkSystem(): Promise { issues.push({ title: "Loaded plugin is outdated", description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`, - fix: "Update: cd ~/.config/opencode && bun update oh-my-opencode", + fix: `Update: cd ${loadedInfo.cacheDir} && bun add oh-my-opencode@latest`, severity: "warning", affects: ["plugin features"], }) diff --git a/src/cli/run/AGENTS.md b/src/cli/run/AGENTS.md index 4f9fb2ec1..c81764a04 100644 --- a/src/cli/run/AGENTS.md +++ b/src/cli/run/AGENTS.md @@ -1,6 +1,6 @@ # src/cli/run/ — Non-Interactive Session Launcher -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/cli/run/events.test.ts b/src/cli/run/events.test.ts index 2afc216f8..502db8623 100644 --- a/src/cli/run/events.test.ts +++ b/src/cli/run/events.test.ts @@ -318,14 +318,8 @@ describe("event handling", () => { // given const ctx = createMockContext("my-session") const state: EventState = { + ...createEventState(), mainSessionIdle: true, - mainSessionError: false, - lastError: null, - lastOutput: "", - lastPartText: "", - currentTool: null, - hasReceivedMeaningfulWork: false, - messageCount: 0, } const payload: EventPayload = { diff --git a/src/config/AGENTS.md b/src/config/AGENTS.md index 83a8830a3..9b443d3c6 100644 --- a/src/config/AGENTS.md +++ b/src/config/AGENTS.md @@ -1,10 +1,10 @@ # src/config/ — Zod v4 Schema System -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -22 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional — omitted fields use plugin defaults. +24 schema files composing `OhMyOpenCodeConfigSchema`. Zod v4 validation with `safeParse()`. All fields optional — omitted fields use plugin defaults. ## SCHEMA TREE @@ -29,14 +29,18 @@ config/schema/ ├── git-master.ts # commit_footer: boolean | string ├── browser-automation.ts # provider: playwright | agent-browser | playwright-cli ├── background-task.ts # Concurrency limits per model/provider +├── fallback-models.ts # FallbackModelsConfigSchema +├── runtime-fallback.ts # RuntimeFallbackConfigSchema ├── babysitting.ts # Unstable agent monitoring ├── dynamic-context-pruning.ts # Context pruning settings +├── start-work.ts # StartWorkConfigSchema (auto_commit) └── internal/permission.ts # AgentPermissionSchema + ``` -## ROOT SCHEMA FIELDS (27) +## ROOT SCHEMA FIELDS (28) -`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `_migrations` +`$schema`, `new_task_system_enabled`, `default_run_agent`, `disabled_mcps`, `disabled_agents`, `disabled_skills`, `disabled_hooks`, `disabled_commands`, `disabled_tools`, `hashline_edit`, `agents`, `categories`, `claude_code`, `sisyphus_agent`, `comment_checker`, `experimental`, `auto_update`, `skills`, `ralph_loop`, `background_task`, `notification`, `babysitting`, `git_master`, `browser_automation_engine`, `websearch`, `tmux`, `sisyphus`, `start_work`, `_migrations` ## AGENT OVERRIDE FIELDS (21) diff --git a/src/config/index.ts b/src/config/index.ts index 2f7f98578..ae2ef967f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,11 +1,25 @@ export { OhMyOpenCodeConfigSchema, + AgentOverrideConfigSchema, + AgentOverridesSchema, + CustomAgentOverridesSchema, + McpNameSchema, + AgentNameSchema, + OverridableAgentNameSchema, + HookNameSchema, + BuiltinCommandNameSchema, + SisyphusAgentConfigSchema, + ExperimentalConfigSchema, + RalphLoopConfigSchema, + TmuxConfigSchema, + TmuxLayoutSchema, } from "./schema" export type { OhMyOpenCodeConfig, AgentOverrideConfig, AgentOverrides, + CustomAgentOverrides, McpName, AgentName, HookName, diff --git a/src/config/schema-document.test.ts b/src/config/schema-document.test.ts new file mode 100644 index 000000000..80bc6d078 --- /dev/null +++ b/src/config/schema-document.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test" +import { createOhMyOpenCodeJsonSchema } from "../../script/build-schema-document" + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +describe("schema document generation", () => { + test("custom_agents schema allows arbitrary custom agent keys with override shape", () => { + // given + const schema = createOhMyOpenCodeJsonSchema() + + // when + const rootProperties = asRecord(schema.properties) + const agentsSchema = asRecord(rootProperties?.agents) + const customAgentsSchema = asRecord(rootProperties?.custom_agents) + const customPropertyNames = asRecord(customAgentsSchema?.propertyNames) + const customAdditionalProperties = asRecord(customAgentsSchema?.additionalProperties) + const defs = asRecord(schema.$defs) + const sharedAgentOverrideSchema = asRecord(defs?.agentOverrideConfig) + const sharedAgentProperties = asRecord(sharedAgentOverrideSchema?.properties) + + // then + expect(agentsSchema).toBeDefined() + expect(agentsSchema?.additionalProperties).toBeFalse() + expect(customAgentsSchema).toBeDefined() + expect(customPropertyNames?.pattern).toBeDefined() + expect(customPropertyNames?.pattern).toContain("[bB][uU][iI][lL][dD]") + expect(customPropertyNames?.pattern).toContain("[pP][lL][aA][nN]") + expect(customAdditionalProperties).toBeDefined() + expect(customAdditionalProperties?.$ref).toBe("#/$defs/agentOverrideConfig") + expect(sharedAgentOverrideSchema).toBeDefined() + expect(sharedAgentProperties?.model).toEqual({ type: "string" }) + expect(sharedAgentProperties?.temperature).toEqual( + expect.objectContaining({ type: "number" }), + ) + }) +}) diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 8a83fcd7d..477eaa51b 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -530,6 +530,79 @@ describe("Sisyphus-Junior agent override", () => { expect(result.data.agents?.momus?.category).toBe("quick") } }) + + test("schema accepts custom_agents override keys", () => { + // given + const config = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.data.custom_agents?.translator?.temperature).toBe(0) + } + }) + + test("schema rejects unknown keys under agents", () => { + // given + const config = { + agents: { + sisyphuss: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents", () => { + // given + const config = { + custom_agents: { + sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) + + test("schema rejects built-in agent names under custom_agents case-insensitively", () => { + // given + const config = { + custom_agents: { + Sisyphus: { + model: "openai/gpt-5.3-codex", + }, + }, + } + + // when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // then + expect(result.success).toBe(false) + }) }) describe("BrowserAutomationProviderSchema", () => { diff --git a/src/config/schema/agent-overrides.ts b/src/config/schema/agent-overrides.ts index 623b35efd..bc40a7313 100644 --- a/src/config/schema/agent-overrides.ts +++ b/src/config/schema/agent-overrides.ts @@ -1,5 +1,6 @@ import { z } from "zod" import { FallbackModelsSchema } from "./fallback-models" +import { OverridableAgentNameSchema } from "./agent-names" import { AgentPermissionSchema } from "./internal/permission" export const AgentOverrideConfigSchema = z.object({ @@ -55,7 +56,7 @@ export const AgentOverrideConfigSchema = z.object({ .optional(), }) -export const AgentOverridesSchema = z.object({ +const BuiltinAgentOverridesSchema = z.object({ build: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(), sisyphus: AgentOverrideConfigSchema.optional(), @@ -72,7 +73,57 @@ export const AgentOverridesSchema = z.object({ explore: AgentOverrideConfigSchema.optional(), "multimodal-looker": AgentOverrideConfigSchema.optional(), atlas: AgentOverrideConfigSchema.optional(), -}) +}).strict() + +export const AgentOverridesSchema = BuiltinAgentOverridesSchema + +const RESERVED_CUSTOM_AGENT_NAMES = OverridableAgentNameSchema.options +const RESERVED_CUSTOM_AGENT_NAME_SET = new Set( + RESERVED_CUSTOM_AGENT_NAMES.map((name) => name.toLowerCase()), +) +function escapeRegexLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function toCaseInsensitiveLiteralPattern(value: string): string { + return value + .split("") + .map((char) => { + if (/^[A-Za-z]$/.test(char)) { + const lower = char.toLowerCase() + const upper = char.toUpperCase() + return `[${lower}${upper}]` + } + + return escapeRegexLiteral(char) + }) + .join("") +} + +const RESERVED_CUSTOM_AGENT_NAME_PATTERN = new RegExp( + `^(?!(?:${RESERVED_CUSTOM_AGENT_NAMES.map(toCaseInsensitiveLiteralPattern).join("|")})$).+`, +) + +export const CustomAgentOverridesSchema = z + .record( + z.string().regex( + RESERVED_CUSTOM_AGENT_NAME_PATTERN, + "custom_agents key cannot reuse built-in agent override name", + ), + AgentOverrideConfigSchema, + ) + .superRefine((value, ctx) => { + for (const key of Object.keys(value)) { + if (RESERVED_CUSTOM_AGENT_NAME_SET.has(key.toLowerCase())) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: "custom_agents key cannot reuse built-in agent override name", + }) + } + } + }) export type AgentOverrideConfig = z.infer export type AgentOverrides = z.infer +export type CustomAgentOverrides = z.infer diff --git a/src/config/schema/background-task.test.ts b/src/config/schema/background-task.test.ts new file mode 100644 index 000000000..2ca225864 --- /dev/null +++ b/src/config/schema/background-task.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test" +import { ZodError } from "zod/v4" +import { BackgroundTaskConfigSchema } from "./background-task" + +describe("BackgroundTaskConfigSchema", () => { + describe("syncPollTimeoutMs", () => { + describe("#given valid syncPollTimeoutMs (120000)", () => { + test("#when parsed #then returns correct value", () => { + const result = BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 120000 }) + + expect(result.syncPollTimeoutMs).toBe(120000) + }) + }) + + describe("#given syncPollTimeoutMs below minimum (59999)", () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: 59999 }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + + describe("#given syncPollTimeoutMs not provided", () => { + test("#when parsed #then field is undefined", () => { + const result = BackgroundTaskConfigSchema.parse({}) + + expect(result.syncPollTimeoutMs).toBeUndefined() + }) + }) + + describe('#given syncPollTimeoutMs is non-number ("abc")', () => { + test("#when parsed #then throws ZodError", () => { + let thrownError: unknown + + try { + BackgroundTaskConfigSchema.parse({ syncPollTimeoutMs: "abc" }) + } catch (error) { + thrownError = error + } + + expect(thrownError).toBeInstanceOf(ZodError) + }) + }) + }) +}) diff --git a/src/config/schema/background-task.ts b/src/config/schema/background-task.ts index 233fe2863..b955de6b5 100644 --- a/src/config/schema/background-task.ts +++ b/src/config/schema/background-task.ts @@ -8,6 +8,7 @@ export const BackgroundTaskConfigSchema = z.object({ staleTimeoutMs: z.number().min(60000).optional(), /** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */ messageStalenessTimeoutMs: z.number().min(60000).optional(), + syncPollTimeoutMs: z.number().min(60000).optional(), }) export type BackgroundTaskConfig = z.infer diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 8a7ecfdfb..28ab58851 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -49,6 +49,7 @@ export const HookNameSchema = z.enum([ "write-existing-file-guard", "anthropic-effort", "hashline-read-enhancer", + "read-image-resizer", ]) export type HookName = z.infer diff --git a/src/config/schema/oh-my-opencode-config.ts b/src/config/schema/oh-my-opencode-config.ts index 52e7d461e..43a7c6a4f 100644 --- a/src/config/schema/oh-my-opencode-config.ts +++ b/src/config/schema/oh-my-opencode-config.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { AnyMcpNameSchema } from "../../mcp/types" import { BuiltinAgentNameSchema, BuiltinSkillNameSchema } from "./agent-names" -import { AgentOverridesSchema } from "./agent-overrides" +import { AgentOverridesSchema, CustomAgentOverridesSchema } from "./agent-overrides" import { BabysittingConfigSchema } from "./babysitting" import { BackgroundTaskConfigSchema } from "./background-task" import { BrowserAutomationConfigSchema } from "./browser-automation" @@ -18,6 +18,7 @@ import { SkillsConfigSchema } from "./skills" import { SisyphusConfigSchema } from "./sisyphus" import { SisyphusAgentConfigSchema } from "./sisyphus-agent" import { TmuxConfigSchema } from "./tmux" +import { StartWorkConfigSchema } from "./start-work" import { WebsearchConfigSchema } from "./websearch" export const OhMyOpenCodeConfigSchema = z.object({ @@ -33,11 +34,12 @@ export const OhMyOpenCodeConfigSchema = z.object({ disabled_commands: z.array(BuiltinCommandNameSchema).optional(), /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ disabled_tools: z.array(z.string()).optional(), - /** Enable hashline_edit tool/hook integrations (default: true at call site) */ + /** Enable hashline_edit tool/hook integrations (default: false) */ hashline_edit: z.boolean().optional(), /** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */ model_fallback: z.boolean().optional(), agents: AgentOverridesSchema.optional(), + custom_agents: CustomAgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), sisyphus_agent: SisyphusAgentConfigSchema.optional(), @@ -60,6 +62,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ websearch: WebsearchConfigSchema.optional(), tmux: TmuxConfigSchema.optional(), sisyphus: SisyphusConfigSchema.optional(), + start_work: StartWorkConfigSchema.optional(), /** Migration history to prevent re-applying migrations (e.g., model version upgrades) */ _migrations: z.array(z.string()).optional(), }) diff --git a/src/config/schema/start-work.ts b/src/config/schema/start-work.ts new file mode 100644 index 000000000..7daae0c3d --- /dev/null +++ b/src/config/schema/start-work.ts @@ -0,0 +1,8 @@ +import { z } from "zod" + +export const StartWorkConfigSchema = z.object({ + /** Enable auto-commit after each atomic task completion (default: true) */ + auto_commit: z.boolean().default(true), +}) + +export type StartWorkConfig = z.infer diff --git a/src/create-hooks.ts b/src/create-hooks.ts index 9972551e8..121b0f53e 100644 --- a/src/create-hooks.ts +++ b/src/create-hooks.ts @@ -51,6 +51,7 @@ export function createHooks(args: { const skill = createSkillHooks({ ctx, + pluginConfig, isHookEnabled, safeHookEnabled, mergedSkills, diff --git a/src/features/AGENTS.md b/src/features/AGENTS.md index cec212e57..9a000826c 100644 --- a/src/features/AGENTS.md +++ b/src/features/AGENTS.md @@ -1,6 +1,6 @@ # src/features/ — 19 Feature Modules -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/background-agent/AGENTS.md b/src/features/background-agent/AGENTS.md index 0b4b18ec0..615bb8e25 100644 --- a/src/features/background-agent/AGENTS.md +++ b/src/features/background-agent/AGENTS.md @@ -1,10 +1,10 @@ # src/features/background-agent/ — Core Orchestration Engine -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -39 files (~10k LOC). Manages async task lifecycle: launch → queue → run → poll → complete/error. Concurrency limited per model/provider (default 5). Central to multi-agent orchestration. +30 files (~10k LOC). Manages async task lifecycle: launch → queue → run → poll → complete/error. Concurrency limited per model/provider (default 5). Central to multi-agent orchestration. ## TASK LIFECYCLE diff --git a/src/features/claude-code-plugin-loader/types.ts b/src/features/claude-code-plugin-loader/types.ts index 34e01937d..f384f4ef6 100644 --- a/src/features/claude-code-plugin-loader/types.ts +++ b/src/features/claude-code-plugin-loader/types.ts @@ -80,12 +80,11 @@ export interface PluginManifest { /** * Hooks configuration */ -export interface HookEntry { - type: "command" | "prompt" | "agent" - command?: string - prompt?: string - agent?: string -} +export type HookEntry = + | { type: "command"; command?: string } + | { type: "prompt"; prompt?: string } + | { type: "agent"; agent?: string } + | { type: "http"; url: string; headers?: Record; allowedEnvVars?: string[]; timeout?: number } export interface HookMatcher { matcher?: string diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md index 25d00ae04..9b444252f 100644 --- a/src/features/claude-tasks/AGENTS.md +++ b/src/features/claude-tasks/AGENTS.md @@ -1,6 +1,6 @@ # src/features/claude-tasks/ — Task Schema + Storage -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/context-injector/collector.test.ts b/src/features/context-injector/collector.test.ts index 695ff4af8..4001b5483 100644 --- a/src/features/context-injector/collector.test.ts +++ b/src/features/context-injector/collector.test.ts @@ -205,6 +205,45 @@ describe("ContextCollector", () => { const ids = pending.entries.map((e) => e.id) expect(ids).toEqual(["first", "second", "third"]) }) + + it("keeps registration order even when Date.now values are not monotonic", () => { + // given + const sessionID = "ses_order_non_monotonic_time" + const originalDateNow = Date.now + const mockedTimestamps = [300, 100, 200] + let timestampIndex = 0 + Date.now = () => mockedTimestamps[timestampIndex++] ?? 0 + + try { + collector.register(sessionID, { + id: "first", + source: "custom", + content: "First", + priority: "normal", + }) + collector.register(sessionID, { + id: "second", + source: "custom", + content: "Second", + priority: "normal", + }) + collector.register(sessionID, { + id: "third", + source: "custom", + content: "Third", + priority: "normal", + }) + } finally { + Date.now = originalDateNow + } + + // when + const pending = collector.getPending(sessionID) + + // then + const ids = pending.entries.map((entry) => entry.id) + expect(ids).toEqual(["first", "second", "third"]) + }) }) describe("consume", () => { diff --git a/src/features/context-injector/collector.ts b/src/features/context-injector/collector.ts index af60e4196..f1b9f61ab 100644 --- a/src/features/context-injector/collector.ts +++ b/src/features/context-injector/collector.ts @@ -14,6 +14,8 @@ const PRIORITY_ORDER: Record = { const CONTEXT_SEPARATOR = "\n\n---\n\n" +let registrationCounter = 0 + export class ContextCollector { private sessions: Map> = new Map() @@ -30,7 +32,7 @@ export class ContextCollector { source: options.source, content: options.content, priority: options.priority ?? "normal", - timestamp: Date.now(), + registrationOrder: ++registrationCounter, metadata: options.metadata, } @@ -77,7 +79,7 @@ export class ContextCollector { return entries.sort((a, b) => { const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority] if (priorityDiff !== 0) return priorityDiff - return a.timestamp - b.timestamp + return a.registrationOrder - b.registrationOrder }) } } diff --git a/src/features/context-injector/injector.test.ts b/src/features/context-injector/injector.test.ts index 6fe9e7e81..09de376fe 100644 --- a/src/features/context-injector/injector.test.ts +++ b/src/features/context-injector/injector.test.ts @@ -64,6 +64,51 @@ describe("createContextInjectorMessagesTransformHook", () => { expect(output.messages[2].parts[1].text).toBe("Second message") }) + it("uses deterministic synthetic part ID across repeated transforms", async () => { + // given + const hook = createContextInjectorMessagesTransformHook(collector) + const sessionID = "ses_transform_deterministic" + const baseMessage = createMockMessage("user", "Stable message", sessionID) + + collector.register(sessionID, { + id: "ctx-1", + source: "keyword-detector", + content: "Injected context", + }) + const firstOutput = { + messages: [structuredClone(baseMessage)], + } + + // when + await hook["experimental.chat.messages.transform"]!({}, firstOutput) + + // then + const firstSyntheticPart = firstOutput.messages[0].parts[0] + expect( + "synthetic" in firstSyntheticPart && firstSyntheticPart.synthetic === true + ).toBe(true) + + // given + collector.register(sessionID, { + id: "ctx-2", + source: "keyword-detector", + content: "Injected context", + }) + const secondOutput = { + messages: [structuredClone(baseMessage)], + } + + // when + await hook["experimental.chat.messages.transform"]!({}, secondOutput) + + // then + const secondSyntheticPart = secondOutput.messages[0].parts[0] + expect( + "synthetic" in secondSyntheticPart && secondSyntheticPart.synthetic === true + ).toBe(true) + expect(secondSyntheticPart.id).toBe(firstSyntheticPart.id) + }) + it("does nothing when no pending context", async () => { // given const hook = createContextInjectorMessagesTransformHook(collector) diff --git a/src/features/context-injector/injector.ts b/src/features/context-injector/injector.ts index ca676a11e..8a52de914 100644 --- a/src/features/context-injector/injector.ts +++ b/src/features/context-injector/injector.ts @@ -148,7 +148,7 @@ export function createContextInjectorMessagesTransformHook( // synthetic part pattern (minimal fields) const syntheticPart = { - id: `synthetic_hook_${Date.now()}`, + id: `synthetic_hook_${sessionID}`, messageID: lastUserMessage.info.id, sessionID: (lastUserMessage.info as { sessionID?: string }).sessionID ?? "", type: "text" as const, diff --git a/src/features/context-injector/types.ts b/src/features/context-injector/types.ts index c203be981..23030d0e9 100644 --- a/src/features/context-injector/types.ts +++ b/src/features/context-injector/types.ts @@ -27,8 +27,8 @@ export interface ContextEntry { content: string /** Priority for ordering (default: normal) */ priority: ContextPriority - /** Timestamp when registered */ - timestamp: number + /** Monotonic order when registered */ + registrationOrder: number /** Optional metadata for debugging/logging */ metadata?: Record } diff --git a/src/features/hook-message-injector/injector.test.ts b/src/features/hook-message-injector/injector.test.ts index fffdf5a7d..6481e8851 100644 --- a/src/features/hook-message-injector/injector.test.ts +++ b/src/features/hook-message-injector/injector.test.ts @@ -4,6 +4,8 @@ import { findFirstMessageWithAgent, findNearestMessageWithFieldsFromSDK, findFirstMessageWithAgentFromSDK, + generateMessageId, + generatePartId, injectHookMessage, } from "./injector" import { isSqliteBackend, resetSqliteBackendCache } from "../../shared/opencode-storage-detection" @@ -192,6 +194,40 @@ describe("findFirstMessageWithAgentFromSDK", () => { }) }) +describe("generateMessageId", () => { + it("returns deterministic sequential IDs with fixed format", () => { + // given + const format = /^msg_[0-9a-f]{8}_\d{6}$/ + + // when + const firstId = generateMessageId() + const secondId = generateMessageId() + + // then + expect(firstId).toMatch(format) + expect(secondId).toMatch(format) + expect(secondId.split("_")[1]).toBe(firstId.split("_")[1]) + expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1) + }) +}) + +describe("generatePartId", () => { + it("returns deterministic sequential IDs with fixed format", () => { + // given + const format = /^prt_[0-9a-f]{8}_\d{6}$/ + + // when + const firstId = generatePartId() + const secondId = generatePartId() + + // then + expect(firstId).toMatch(format) + expect(secondId).toMatch(format) + expect(secondId.split("_")[1]).toBe(firstId.split("_")[1]) + expect(Number(secondId.split("_")[2])).toBe(Number(firstId.split("_")[2]) + 1) + }) +}) + describe("injectHookMessage", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/features/hook-message-injector/injector.ts b/src/features/hook-message-injector/injector.ts index 8f4e0d57b..4d43f025b 100644 --- a/src/features/hook-message-injector/injector.ts +++ b/src/features/hook-message-injector/injector.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" +import { randomBytes } from "node:crypto" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" @@ -29,6 +30,10 @@ interface SDKMessage { } } +const processPrefix = randomBytes(4).toString("hex") +let messageCounter = 0 +let partCounter = 0 + function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { const info = msg.info if (!info) return null @@ -204,16 +209,12 @@ export function findFirstMessageWithAgent(messageDir: string): string | null { return null } -function generateMessageId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 14) - return `msg_${timestamp}${random}` +export function generateMessageId(): string { + return `msg_${processPrefix}_${String(++messageCounter).padStart(6, "0")}` } -function generatePartId(): string { - const timestamp = Date.now().toString(16) - const random = Math.random().toString(36).substring(2, 10) - return `prt_${timestamp}${random}` +export function generatePartId(): string { + return `prt_${processPrefix}_${String(++partCounter).padStart(6, "0")}` } function getOrCreateMessageDir(sessionID: string): string { diff --git a/src/features/mcp-oauth/AGENTS.md b/src/features/mcp-oauth/AGENTS.md index 97f017c29..237c62e12 100644 --- a/src/features/mcp-oauth/AGENTS.md +++ b/src/features/mcp-oauth/AGENTS.md @@ -1,6 +1,6 @@ # src/features/mcp-oauth/ — OAuth 2.0 + PKCE + DCR for MCP Servers -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/opencode-skill-loader/AGENTS.md b/src/features/opencode-skill-loader/AGENTS.md index 5c617673e..447366f0d 100644 --- a/src/features/opencode-skill-loader/AGENTS.md +++ b/src/features/opencode-skill-loader/AGENTS.md @@ -1,6 +1,6 @@ # src/features/opencode-skill-loader/ — 4-Scope Skill Discovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/features/tmux-subagent/AGENTS.md b/src/features/tmux-subagent/AGENTS.md index 69e8ccfa2..73119c9f2 100644 --- a/src/features/tmux-subagent/AGENTS.md +++ b/src/features/tmux-subagent/AGENTS.md @@ -1,6 +1,6 @@ # src/features/tmux-subagent/ — Tmux Pane Management -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/AGENTS.md b/src/hooks/AGENTS.md index 6e22ff9b4..277500af2 100644 --- a/src/hooks/AGENTS.md +++ b/src/hooks/AGENTS.md @@ -1,10 +1,10 @@ # src/hooks/ — 46 Lifecycle Hooks -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW -46 hooks across 39 directories + 6 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. +46 hooks across 45 directories + 11 standalone files. Three-tier composition: Core(37) + Continuation(7) + Skill(2). All hooks follow `createXXXHook(deps) → HookFunction` factory pattern. ## HOOK TIERS @@ -14,38 +14,48 @@ hooks/ ├── atlas/ # Main orchestration (757 lines) ├── anthropic-context-window-limit-recovery/ # Auto-summarize -├── todo-continuation-enforcer.ts # Force TODO completion -├── ralph-loop/ # Self-referential dev loop -├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md -├── comment-checker/ # Prevents AI slop +├── anthropic-effort/ # Reasoning effort level adjustment +├── anthropic-image-context/ # Image context handling for Anthropic ├── auto-slash-command/ # Detects /command patterns -├── rules-injector/ # Conditional rules +├── auto-update-checker/ # Plugin update check +├── background-notification/ # OS notification +├── beast-mode-system/ # Beast mode system prompt injection +├── category-skill-reminder/ # Reminds of category skills +├── claude-code-hooks/ # settings.json compat layer +├── comment-checker/ # Prevents AI slop +├── compaction-context-injector/ # Injects context on compaction +├── compaction-todo-preserver/ # Preserves todos through compaction +├── delegate-task-retry/ # Retries failed delegations ├── directory-agents-injector/ # Auto-injects AGENTS.md ├── directory-readme-injector/ # Auto-injects README.md ├── edit-error-recovery/ # Recovers from failures -├── thinking-block-validator/ # Ensures valid -├── context-window-monitor.ts # Reminds of headroom -├── session-recovery/ # Auto-recovers from crashes -├── think-mode/ # Dynamic thinking budget -├── keyword-detector/ # ultrawork/search/analyze modes -├── background-notification/ # OS notification -├── prometheus-md-only/ # Planner read-only mode -├── agent-usage-reminder/ # Specialized agent hints -├── auto-update-checker/ # Plugin update check -├── tool-output-truncator.ts # Prevents context bloat -├── compaction-context-injector/ # Injects context on compaction -├── delegate-task-retry/ # Retries failed delegations +├── hashline-edit-diff-enhancer/ # Enhanced diff output for hashline edits +├── hashline-read-enhancer/ # Adds LINE#ID hashes to Read output ├── interactive-bash-session/ # Tmux session management +├── json-error-recovery/ # JSON parse error correction +├── keyword-detector/ # ultrawork/search/analyze modes +├── model-fallback/ # Provider-level model fallback +├── no-hephaestus-non-gpt/ # Block Hephaestus from non-GPT +├── no-sisyphus-gpt/ # Block Sisyphus from GPT ├── non-interactive-env/ # Non-TTY environment handling -├── start-work/ # Sisyphus work session starter -├── task-resume-info/ # Resume info for cancelled tasks +├── prometheus-md-only/ # Planner read-only mode ├── question-label-truncator/ # Auto-truncates question labels -├── category-skill-reminder/ # Reminds of category skills -├── empty-task-response-detector.ts # Detects empty responses -├── sisyphus-junior-notepad/ # Sisyphus Junior notepad -├── stop-continuation-guard/ # Guards stop continuation -├── subagent-question-blocker/ # Blocks subagent questions +├── ralph-loop/ # Self-referential dev loop +├── read-image-resizer/ # Resize images for context efficiency +├── rules-injector/ # Conditional rules ├── runtime-fallback/ # Auto-switch models on API errors +├── session-recovery/ # Auto-recovers from crashes +├── sisyphus-junior-notepad/ # Sisyphus Junior notepad +├── start-work/ # Sisyphus work session starter +├── stop-continuation-guard/ # Guards stop continuation +├── task-reminder/ # Task system usage reminders +├── task-resume-info/ # Resume info for cancelled tasks +├── tasks-todowrite-disabler/ # Disable TodoWrite when task system active +├── think-mode/ # Dynamic thinking budget +├── thinking-block-validator/ # Ensures valid +├── todo-continuation-enforcer/ # Force TODO completion +├── unstable-agent-babysitter/ # Monitor unstable agent behavior +├── write-existing-file-guard/ # Require Read before Write └── index.ts # Hook aggregation + registration ``` diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index bc7f3243f..ef2a7b3d9 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -6,6 +6,8 @@ import { } from "./storage"; import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants"; import type { AgentUsageState } from "./types"; +import { getSessionAgent } from "../../features/claude-code-session-state"; +import { getAgentConfigKey } from "../../shared/agent-display-names"; interface ToolExecuteInput { tool: string; @@ -26,6 +28,23 @@ interface EventInput { }; } +/** + * Only orchestrator agents should receive usage reminders. + * Subagents (explore, librarian, oracle, etc.) are the targets of delegation, + * so reminding them to delegate to themselves is counterproductive. + */ +const ORCHESTRATOR_AGENTS = new Set([ + "sisyphus", + "sisyphus-junior", + "atlas", + "hephaestus", + "prometheus", +]); + +function isOrchestratorAgent(agentName: string): boolean { + return ORCHESTRATOR_AGENTS.has(getAgentConfigKey(agentName)); +} + export function createAgentUsageReminderHook(_ctx: PluginInput) { const sessionStates = new Map(); @@ -60,6 +79,12 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { output: ToolExecuteOutput, ) => { const { tool, sessionID } = input; + + const agent = getSessionAgent(sessionID); + if (agent && !isOrchestratorAgent(agent)) { + return; + } + const toolLower = tool.toLowerCase(); if (AGENT_TOOLS.has(toolLower)) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md b/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md index 0234760e4..5da2ecf8f 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md +++ b/src/hooks/anthropic-context-window-limit-recovery/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/anthropic-context-window-limit-recovery/ — Multi-Strategy Context Recovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/anthropic-effort/hook.ts b/src/hooks/anthropic-effort/hook.ts index 06a754d23..16e2656c2 100644 --- a/src/hooks/anthropic-effort/hook.ts +++ b/src/hooks/anthropic-effort/hook.ts @@ -1,11 +1,7 @@ -import { log } from "../../shared" +import { log, normalizeModelID } from "../../shared" const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i -function normalizeModelID(modelID: string): string { - return modelID.replace(/\.(\d+)/g, "-$1") -} - function isClaudeProvider(providerID: string, modelID: string): boolean { if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true diff --git a/src/hooks/atlas/AGENTS.md b/src/hooks/atlas/AGENTS.md index e0c435e2d..63c9cc223 100644 --- a/src/hooks/atlas/AGENTS.md +++ b/src/hooks/atlas/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/atlas/ — Master Boulder Orchestrator -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/atlas/atlas-hook.ts b/src/hooks/atlas/atlas-hook.ts index 94a6470e9..97d0842d7 100644 --- a/src/hooks/atlas/atlas-hook.ts +++ b/src/hooks/atlas/atlas-hook.ts @@ -7,6 +7,7 @@ import type { AtlasHookOptions, SessionState } from "./types" export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { const sessions = new Map() const pendingFilePaths = new Map() + const autoCommit = options?.autoCommit ?? true function getState(sessionID: string): SessionState { let state = sessions.get(sessionID) @@ -20,6 +21,6 @@ export function createAtlasHook(ctx: PluginInput, options?: AtlasHookOptions) { return { handler: createAtlasEventHandler({ ctx, options, sessions, getState }), "tool.execute.before": createToolExecuteBeforeHandler({ ctx, pendingFilePaths }), - "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths }), + "tool.execute.after": createToolExecuteAfterHandler({ ctx, pendingFilePaths, autoCommit }), } } diff --git a/src/hooks/atlas/tool-execute-after.ts b/src/hooks/atlas/tool-execute-after.ts index 8a7240c48..818fdb737 100644 --- a/src/hooks/atlas/tool-execute-after.ts +++ b/src/hooks/atlas/tool-execute-after.ts @@ -14,9 +14,9 @@ import type { ToolExecuteAfterInput, ToolExecuteAfterOutput } from "./types" export function createToolExecuteAfterHandler(input: { ctx: PluginInput pendingFilePaths: Map -}): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { - const { ctx, pendingFilePaths } = input - + autoCommit: boolean + }): (toolInput: ToolExecuteAfterInput, toolOutput: ToolExecuteAfterOutput) => Promise { + const { ctx, pendingFilePaths, autoCommit } = input return async (toolInput, toolOutput): Promise => { // Guard against undefined output (e.g., from /review command - see issue #1035) if (!toolOutput) { @@ -76,7 +76,7 @@ export function createToolExecuteAfterHandler(input: { // Preserve original subagent response - critical for debugging failed tasks const originalResponse = toolOutput.output - toolOutput.output = ` +toolOutput.output = ` ## SUBAGENT WORK COMPLETED ${fileChanges} @@ -88,9 +88,8 @@ ${fileChanges} ${originalResponse} -${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId)} +${buildOrchestratorReminder(boulderState.plan_name, progress, subagentSessionId, autoCommit)} ` - log(`[${HOOK_NAME}] Output transformed for orchestrator mode (boulder)`, { plan: boulderState.plan_name, progress: `${progress.completed}/${progress.total}`, diff --git a/src/hooks/atlas/types.ts b/src/hooks/atlas/types.ts index 7302f8307..73436a019 100644 --- a/src/hooks/atlas/types.ts +++ b/src/hooks/atlas/types.ts @@ -8,6 +8,8 @@ export interface AtlasHookOptions { backgroundManager?: BackgroundManager isContinuationStopped?: (sessionID: string) => boolean agentOverrides?: AgentOverrides + /** Enable auto-commit after each atomic task completion (default: true) */ + autoCommit?: boolean } export interface ToolExecuteAfterInput { diff --git a/src/hooks/atlas/verification-reminders.ts b/src/hooks/atlas/verification-reminders.ts index f0c24c549..1955dde32 100644 --- a/src/hooks/atlas/verification-reminders.ts +++ b/src/hooks/atlas/verification-reminders.ts @@ -14,9 +14,22 @@ task(session_id="${sessionId}", prompt="fix: [describe the specific failure]") export function buildOrchestratorReminder( planName: string, progress: { total: number; completed: number }, - sessionId: string + sessionId: string, + autoCommit: boolean = true ): string { const remaining = progress.total - progress.completed + + const commitStep = autoCommit + ? ` +**STEP 8: COMMIT ATOMIC UNIT** + +- Stage ONLY the verified changes +- Commit with clear message describing what was done +` + : "" + + const nextStepNumber = autoCommit ? 9 : 8 + return ` --- @@ -60,13 +73,8 @@ Update the plan file \`.sisyphus/plans/${planName}.md\`: - Use \`Edit\` tool to modify the checkbox **DO THIS BEFORE ANYTHING ELSE. Unmarked = Untracked = Lost progress.** - -**STEP 8: COMMIT ATOMIC UNIT** - -- Stage ONLY the verified changes -- Commit with clear message describing what was done - -**STEP 9: PROCEED TO NEXT TASK** +${commitStep} +**STEP ${nextStepNumber}: PROCEED TO NEXT TASK** - Read the plan file AGAIN to identify the next \`- [ ]\` task - Start immediately - DO NOT STOP diff --git a/src/hooks/auto-slash-command/constants.ts b/src/hooks/auto-slash-command/constants.ts index de2a49a7a..a8bdac19e 100644 --- a/src/hooks/auto-slash-command/constants.ts +++ b/src/hooks/auto-slash-command/constants.ts @@ -3,7 +3,7 @@ export const HOOK_NAME = "auto-slash-command" as const export const AUTO_SLASH_COMMAND_TAG_OPEN = "" export const AUTO_SLASH_COMMAND_TAG_CLOSE = "" -export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/ +export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z@][\w:@/-]*)\s*(.*)/ export const EXCLUDED_COMMANDS = new Set([ "ralph-loop", diff --git a/src/hooks/auto-slash-command/detector.test.ts b/src/hooks/auto-slash-command/detector.test.ts index ce87c2d9c..36eb8bc6d 100644 --- a/src/hooks/auto-slash-command/detector.test.ts +++ b/src/hooks/auto-slash-command/detector.test.ts @@ -102,6 +102,19 @@ After` expect(result?.args).toBe("project") }) + it("should parse namespaced marketplace commands", () => { + // given a namespaced command + const text = "/daplug:run-prompt build bridge" + + // when parsing + const result = parseSlashCommand(text) + + // then should keep full namespaced command + expect(result).not.toBeNull() + expect(result?.command).toBe("daplug:run-prompt") + expect(result?.args).toBe("build bridge") + }) + it("should return null for non-slash text", () => { // given text without slash const text = "regular text" diff --git a/src/hooks/auto-slash-command/executor.test.ts b/src/hooks/auto-slash-command/executor.test.ts new file mode 100644 index 000000000..979215fae --- /dev/null +++ b/src/hooks/auto-slash-command/executor.test.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { executeSlashCommand } from "./executor" + +const ENV_KEYS = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_PLUGINS_HOME", + "CLAUDE_SETTINGS_PATH", + "OPENCODE_CONFIG_DIR", +] as const + +type EnvKey = (typeof ENV_KEYS)[number] +type EnvSnapshot = Record + +function writePluginFixture(baseDir: string): void { + const claudeConfigDir = join(baseDir, "claude-config") + const pluginsHome = join(claudeConfigDir, "plugins") + const settingsPath = join(claudeConfigDir, "settings.json") + const opencodeConfigDir = join(baseDir, "opencode-config") + const pluginInstallPath = join(baseDir, "installed-plugins", "daplug") + const pluginKey = "daplug@1.0.0" + + mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "commands"), { recursive: true }) + + writeFileSync( + join(pluginInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2), + ) + writeFileSync( + join(pluginInstallPath, "commands", "run-prompt.md"), + `--- +description: Run prompt from daplug +--- +Execute daplug prompt flow. +`, + ) + + mkdirSync(pluginsHome, { recursive: true }) + writeFileSync( + join(pluginsHome, "installed_plugins.json"), + JSON.stringify( + { + version: 2, + plugins: { + [pluginKey]: [ + { + scope: "user", + installPath: pluginInstallPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00.000Z", + lastUpdated: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + ), + ) + + mkdirSync(claudeConfigDir, { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + enabledPlugins: { + [pluginKey]: true, + }, + }, + null, + 2, + ), + ) + mkdirSync(opencodeConfigDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + process.env.CLAUDE_SETTINGS_PATH = settingsPath + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir +} + +describe("auto-slash command executor plugin dispatch", () => { + let tempDir = "" + let envSnapshot: EnvSnapshot + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-executor-plugin-test-")) + envSnapshot = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, + CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + writePluginFixture(tempDir) + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const previousValue = envSnapshot[key] + if (previousValue === undefined) { + delete process.env[key] + } else { + process.env[key] = previousValue + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("resolves marketplace plugin commands when plugin loading is enabled", async () => { + const result = await executeSlashCommand( + { + command: "daplug:run-prompt", + args: "ship it", + raw: "/daplug:run-prompt ship it", + }, + { + skills: [], + pluginsEnabled: true, + }, + ) + + expect(result.success).toBe(true) + expect(result.replacementText).toContain("# /daplug:run-prompt Command") + expect(result.replacementText).toContain("**Scope**: plugin") + }) + + it("excludes marketplace commands when plugins are disabled via config toggle", async () => { + const result = await executeSlashCommand( + { + command: "daplug:run-prompt", + args: "", + raw: "/daplug:run-prompt", + }, + { + skills: [], + pluginsEnabled: false, + }, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe( + 'Command "/daplug:run-prompt" not found. Use the skill tool to list available skills and commands.', + ) + }) + + it("returns standard not-found for unknown namespaced commands", async () => { + const result = await executeSlashCommand( + { + command: "daplug:missing", + args: "", + raw: "/daplug:missing", + }, + { + skills: [], + pluginsEnabled: true, + }, + ) + + expect(result.success).toBe(false) + expect(result.error).toBe( + 'Command "/daplug:missing" not found. Use the skill tool to list available skills and commands.', + ) + expect(result.error).not.toContain("Marketplace plugin commands") + }) +}) diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index ffa96be8b..f7c906e20 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -12,10 +12,15 @@ import { loadBuiltinCommands } from "../../features/builtin-commands" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader" +import { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, +} from "../../features/claude-code-plugin-loader" import type { ParsedSlashCommand } from "./types" interface CommandScope { - type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" + type: "user" | "project" | "opencode" | "opencode-project" | "skill" | "builtin" | "plugin" } interface CommandMetadata { @@ -99,6 +104,36 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo { export interface ExecutorOptions { skills?: LoadedSkill[] + pluginsEnabled?: boolean + enabledPluginsOverride?: Record +} + +function discoverPluginCommands(options?: ExecutorOptions): CommandInfo[] { + if (options?.pluginsEnabled === false) { + return [] + } + + const { plugins } = discoverInstalledPlugins({ + enabledPluginsOverride: options?.enabledPluginsOverride, + }) + + const pluginDefinitions = { + ...loadPluginCommands(plugins), + ...loadPluginSkillsAsCommands(plugins), + } + + return Object.entries(pluginDefinitions).map(([name, definition]) => ({ + name, + metadata: { + name, + description: definition.description || "", + model: definition.model, + agent: definition.agent, + subtask: definition.subtask, + }, + content: definition.template, + scope: "plugin", + })) } async function discoverAllCommands(options?: ExecutorOptions): Promise { @@ -128,6 +163,7 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise() export interface AutoSlashCommandHookOptions { skills?: LoadedSkill[] + pluginsEnabled?: boolean + enabledPluginsOverride?: Record } export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) { const executorOptions: ExecutorOptions = { skills: options?.skills, + pluginsEnabled: options?.pluginsEnabled, + enabledPluginsOverride: options?.enabledPluginsOverride, } return { diff --git a/src/hooks/claude-code-hooks/AGENTS.md b/src/hooks/claude-code-hooks/AGENTS.md index 03b88a73c..f9dd368bd 100644 --- a/src/hooks/claude-code-hooks/AGENTS.md +++ b/src/hooks/claude-code-hooks/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/claude-code-hooks/ — Claude Code Compatibility -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/claude-code-hooks/config.ts b/src/hooks/claude-code-hooks/config.ts index 3a03d200b..a2daf0039 100644 --- a/src/hooks/claude-code-hooks/config.ts +++ b/src/hooks/claude-code-hooks/config.ts @@ -1,12 +1,12 @@ import { join } from "path" import { existsSync } from "fs" import { getClaudeConfigDir } from "../../shared" -import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types" +import type { ClaudeHooksConfig, HookMatcher, HookAction } from "./types" interface RawHookMatcher { matcher?: string pattern?: string - hooks: HookCommand[] + hooks: HookAction[] } interface RawClaudeHooksConfig { diff --git a/src/hooks/claude-code-hooks/dispatch-hook.ts b/src/hooks/claude-code-hooks/dispatch-hook.ts new file mode 100644 index 000000000..5feeabb62 --- /dev/null +++ b/src/hooks/claude-code-hooks/dispatch-hook.ts @@ -0,0 +1,27 @@ +import type { HookAction } from "./types" +import type { CommandResult } from "../../shared/command-executor/execute-hook-command" +import { executeHookCommand } from "../../shared" +import { executeHttpHook } from "./execute-http-hook" +import { DEFAULT_CONFIG } from "./plugin-config" + +export function getHookIdentifier(hook: HookAction): string { + if (hook.type === "http") return hook.url + return hook.command.split("/").pop() || hook.command +} + +export async function dispatchHook( + hook: HookAction, + stdinJson: string, + cwd: string +): Promise { + if (hook.type === "http") { + return executeHttpHook(hook, stdinJson) + } + + return executeHookCommand( + hook.command, + stdinJson, + cwd, + { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } + ) +} diff --git a/src/hooks/claude-code-hooks/execute-http-hook.test.ts b/src/hooks/claude-code-hooks/execute-http-hook.test.ts new file mode 100644 index 000000000..682611875 --- /dev/null +++ b/src/hooks/claude-code-hooks/execute-http-hook.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" +import type { HookHttp } from "./types" + +const mockFetch = mock(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) +) + +const originalFetch = globalThis.fetch + +describe("executeHttpHook", () => { + beforeEach(() => { + globalThis.fetch = mockFetch as unknown as typeof fetch + mockFetch.mockReset() + mockFetch.mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({}), { status: 200 })) + ) + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + describe("#given a basic HTTP hook", () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks/pre-tool-use", + } + const stdinData = JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash" }) + + it("#when executed #then sends POST request with correct body", async () => { + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, stdinData) + + expect(mockFetch).toHaveBeenCalledTimes(1) + const [url, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + expect(url).toBe("http://localhost:8080/hooks/pre-tool-use") + expect(options.method).toBe("POST") + expect(options.body).toBe(stdinData) + }) + + it("#when executed #then sets content-type to application/json", async () => { + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, stdinData) + + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Content-Type"]).toBe("application/json") + }) + }) + + describe("#given an HTTP hook with headers and env var interpolation", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv, MY_TOKEN: "secret-123", OTHER_VAR: "other-value" } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("#when allowedEnvVars includes the var #then interpolates env var in headers", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer $MY_TOKEN" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer secret-123") + }) + + it("#when env var uses ${VAR} syntax #then interpolates correctly", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer ${MY_TOKEN}" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer secret-123") + }) + + it("#when env var not in allowedEnvVars #then replaces with empty string", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + headers: { Authorization: "Bearer $OTHER_VAR" }, + allowedEnvVars: ["MY_TOKEN"], + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + const headers = options.headers as Record + expect(headers["Authorization"]).toBe("Bearer ") + }) + }) + + describe("#given an HTTP hook with timeout", () => { + it("#when timeout specified #then passes AbortSignal with timeout", async () => { + const hook: HookHttp = { + type: "http", + url: "http://localhost:8080/hooks", + timeout: 10, + } + const { executeHttpHook } = await import("./execute-http-hook") + + await executeHttpHook(hook, "{}") + + const [, options] = mockFetch.mock.calls[0] as unknown as [string, RequestInit] + expect(options.signal).toBeDefined() + }) + }) + + describe("#given hook URL scheme validation", () => { + it("#when URL uses file:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "file:///etc/passwd" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "file:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses data: scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "data:text/plain,hello" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "data:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses ftp:// scheme #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "ftp://localhost/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain('HTTP hook URL scheme "ftp:" is not allowed') + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("#when URL uses http:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL uses https:// scheme #then allows hook execution", async () => { + const hook: HookHttp = { type: "http", url: "https://example.com/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + it("#when URL is invalid #then rejects with exit code 1", async () => { + const hook: HookHttp = { type: "http", url: "not-a-valid-url" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("HTTP hook URL is invalid: not-a-valid-url") + expect(mockFetch).not.toHaveBeenCalled() + }) + }) + + describe("#given a successful HTTP response", () => { + it("#when response has JSON body #then returns parsed output", async () => { + mockFetch.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ decision: "allow", reason: "ok" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('"decision":"allow"') + }) + }) + + describe("#given a failing HTTP response", () => { + it("#when response status is 4xx #then returns exit code 1", async () => { + mockFetch.mockImplementation(() => + Promise.resolve(new Response("Bad Request", { status: 400 })) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("400") + }) + + it("#when fetch throws network error #then returns exit code 1", async () => { + mockFetch.mockImplementation(() => Promise.reject(new Error("ECONNREFUSED"))) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(1) + expect(result.stderr).toContain("ECONNREFUSED") + }) + }) + + describe("#given response with exit code in JSON", () => { + it("#when JSON contains exitCode 2 #then uses that exit code", async () => { + mockFetch.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ exitCode: 2, stderr: "blocked" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ) + const hook: HookHttp = { type: "http", url: "http://localhost:8080/hooks" } + const { executeHttpHook } = await import("./execute-http-hook") + + const result = await executeHttpHook(hook, "{}") + + expect(result.exitCode).toBe(2) + }) + }) +}) + +describe("interpolateEnvVars", () => { + const originalEnv = process.env + + beforeEach(() => { + process.env = { ...originalEnv, TOKEN: "abc", SECRET: "xyz" } + }) + + afterEach(() => { + process.env = originalEnv + }) + + it("#given $VAR syntax #when var is allowed #then interpolates", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer $TOKEN", ["TOKEN"]) + + expect(result).toBe("Bearer abc") + }) + + it("#given ${VAR} syntax #when var is allowed #then interpolates", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN"]) + + expect(result).toBe("Bearer abc") + }) + + it("#given multiple vars #when some not allowed #then only interpolates allowed ones", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("$TOKEN:$SECRET", ["TOKEN"]) + + expect(result).toBe("abc:") + }) + + it("#given ${VAR} where value contains $ANOTHER #when both allowed #then does not double-interpolate", async () => { + process.env = { ...process.env, TOKEN: "val$SECRET", SECRET: "oops" } + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer ${TOKEN}", ["TOKEN", "SECRET"]) + + expect(result).toBe("Bearer val$SECRET") + }) + + it("#given no allowedEnvVars #when called #then replaces all with empty", async () => { + const { interpolateEnvVars } = await import("./execute-http-hook") + + const result = interpolateEnvVars("Bearer $TOKEN", []) + + expect(result).toBe("Bearer ") + }) +}) diff --git a/src/hooks/claude-code-hooks/execute-http-hook.ts b/src/hooks/claude-code-hooks/execute-http-hook.ts new file mode 100644 index 000000000..1e72817cf --- /dev/null +++ b/src/hooks/claude-code-hooks/execute-http-hook.ts @@ -0,0 +1,92 @@ +import type { HookHttp } from "./types" +import type { CommandResult } from "../../shared/command-executor/execute-hook-command" + +const DEFAULT_HTTP_HOOK_TIMEOUT_S = 30 +const ALLOWED_SCHEMES = new Set(["http:", "https:"]) + +export function interpolateEnvVars( + value: string, + allowedEnvVars: string[] +): string { + const allowedSet = new Set(allowedEnvVars) + + return value.replace(/\$\{(\w+)\}|\$(\w+)/g, (_match, bracedVar: string | undefined, bareVar: string | undefined) => { + const varName = (bracedVar ?? bareVar) as string + if (allowedSet.has(varName)) { + return process.env[varName] ?? "" + } + return "" + }) +} + +function resolveHeaders( + hook: HookHttp +): Record { + const headers: Record = { + "Content-Type": "application/json", + } + + if (!hook.headers) return headers + + const allowedEnvVars = hook.allowedEnvVars ?? [] + for (const [key, value] of Object.entries(hook.headers)) { + headers[key] = interpolateEnvVars(value, allowedEnvVars) + } + + return headers +} + +export async function executeHttpHook( + hook: HookHttp, + stdin: string +): Promise { + try { + const parsed = new URL(hook.url) + if (!ALLOWED_SCHEMES.has(parsed.protocol)) { + return { + exitCode: 1, + stderr: `HTTP hook URL scheme "${parsed.protocol}" is not allowed. Only http: and https: are permitted.`, + } + } + } catch { + return { exitCode: 1, stderr: `HTTP hook URL is invalid: ${hook.url}` } + } + + const timeoutS = hook.timeout ?? DEFAULT_HTTP_HOOK_TIMEOUT_S + const headers = resolveHeaders(hook) + + try { + const response = await fetch(hook.url, { + method: "POST", + headers, + body: stdin, + signal: AbortSignal.timeout(timeoutS * 1000), + }) + + if (!response.ok) { + return { + exitCode: 1, + stderr: `HTTP hook returned status ${response.status}: ${response.statusText}`, + stdout: await response.text().catch(() => ""), + } + } + + const body = await response.text() + if (!body) { + return { exitCode: 0, stdout: "", stderr: "" } + } + + try { + const parsed = JSON.parse(body) as { exitCode?: number } + if (typeof parsed.exitCode === "number") { + return { exitCode: parsed.exitCode, stdout: body, stderr: "" } + } + } catch { + } + + return { exitCode: 0, stdout: body, stderr: "" } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { exitCode: 1, stderr: `HTTP hook error: ${message}` } + } +} diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts index 31b88dc06..3ba1f7208 100644 --- a/src/hooks/claude-code-hooks/post-tool-use.ts +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -3,8 +3,8 @@ import type { PostToolUseOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -94,22 +94,17 @@ export async function executePostToolUseHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) { - log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PostToolUse", hookName, extendedConfig ?? null)) { + log("PostToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = hook.command.split("/").pop() || hook.command if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.stdout) { messages.push(result.stdout) diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts index e2d877396..a3aa01b62 100644 --- a/src/hooks/claude-code-hooks/pre-compact.ts +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -3,8 +3,8 @@ import type { PreCompactOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" export interface PreCompactContext { @@ -50,22 +50,17 @@ export async function executePreCompactHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PreCompact", hook.command, extendedConfig ?? null)) { - log("PreCompact hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreCompact", hookName, extendedConfig ?? null)) { + log("PreCompact hook command skipped (disabled by config)", { command: hookName }) continue } - const hookName = hook.command.split("/").pop() || hook.command if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.exitCode === 2) { log("PreCompact hook blocked", { hookName, stderr: result.stderr }) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index 2b5a33c5c..97bfaf04a 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -4,8 +4,8 @@ import type { PermissionDecision, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" export interface PreToolUseContext { @@ -77,22 +77,17 @@ export async function executePreToolUseHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) { - log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("PreToolUse", hookName, extendedConfig ?? null)) { + log("PreToolUse hook command skipped (disabled by config)", { command: hookName, toolName: ctx.toolName }) continue } - const hookName = hook.command.split("/").pop() || hook.command if (!firstHookName) firstHookName = hookName - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.exitCode === 2) { return { diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts index 0073613b4..5b4423eb8 100644 --- a/src/hooks/claude-code-hooks/stop.ts +++ b/src/hooks/claude-code-hooks/stop.ts @@ -3,8 +3,8 @@ import type { StopOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { getTodoPath } from "./todo" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -68,19 +68,15 @@ export async function executeStopHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) { - log("Stop hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("Stop", hookName, extendedConfig ?? null)) { + log("Stop hook command skipped (disabled by config)", { command: hookName }) continue } - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) // Check exit code first - exit code 2 means block if (result.exitCode === 2) { diff --git a/src/hooks/claude-code-hooks/types.ts b/src/hooks/claude-code-hooks/types.ts index 5d287f6ea..28924de10 100644 --- a/src/hooks/claude-code-hooks/types.ts +++ b/src/hooks/claude-code-hooks/types.ts @@ -12,7 +12,7 @@ export type ClaudeHookEvent = export interface HookMatcher { matcher: string - hooks: HookCommand[] + hooks: HookAction[] } export interface HookCommand { @@ -20,6 +20,16 @@ export interface HookCommand { command: string } +export interface HookHttp { + type: "http" + url: string + headers?: Record + allowedEnvVars?: string[] + timeout?: number +} + +export type HookAction = HookCommand | HookHttp + export interface ClaudeHooksConfig { PreToolUse?: HookMatcher[] PostToolUse?: HookMatcher[] diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index 4fa732ae6..e714eb6bd 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -3,8 +3,8 @@ import type { PostToolUseOutput, ClaudeHooksConfig, } from "./types" -import { findMatchingHooks, executeHookCommand, log } from "../../shared" -import { DEFAULT_CONFIG } from "./plugin-config" +import { findMatchingHooks, log } from "../../shared" +import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" const USER_PROMPT_SUBMIT_TAG_OPEN = "" @@ -80,19 +80,15 @@ export async function executeUserPromptSubmitHooks( for (const matcher of matchers) { if (!matcher.hooks || matcher.hooks.length === 0) continue for (const hook of matcher.hooks) { - if (hook.type !== "command") continue + if (hook.type !== "command" && hook.type !== "http") continue - if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) { - log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command }) + const hookName = getHookIdentifier(hook) + if (isHookCommandDisabled("UserPromptSubmit", hookName, extendedConfig ?? null)) { + log("UserPromptSubmit hook command skipped (disabled by config)", { command: hookName }) continue } - const result = await executeHookCommand( - hook.command, - JSON.stringify(stdinData), - ctx.cwd, - { forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath } - ) + const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.stdout) { const output = result.stdout.trim() diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f992b0d7d..171f5dd12 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -50,3 +50,4 @@ export { createRuntimeFallbackHook, type RuntimeFallbackHook, type RuntimeFallba export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery"; +export { createReadImageResizerHook } from "./read-image-resizer" diff --git a/src/hooks/keyword-detector/AGENTS.md b/src/hooks/keyword-detector/AGENTS.md index 34f081182..94b374b2b 100644 --- a/src/hooks/keyword-detector/AGENTS.md +++ b/src/hooks/keyword-detector/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/keyword-detector/ — Mode Keyword Injection -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/model-fallback/hook.test.ts b/src/hooks/model-fallback/hook.test.ts index 4d30d5b0b..348f163a1 100644 --- a/src/hooks/model-fallback/hook.test.ts +++ b/src/hooks/model-fallback/hook.test.ts @@ -3,12 +3,15 @@ import { beforeEach, describe, expect, test } from "bun:test" import { clearPendingModelFallback, createModelFallbackHook, + setSessionFallbackChain, setPendingModelFallback, } from "./hook" describe("model fallback hook", () => { beforeEach(() => { clearPendingModelFallback("ses_model_fallback_main") + clearPendingModelFallback("ses_model_fallback_ghcp") + clearPendingModelFallback("ses_model_fallback_google") }) test("applies pending fallback on chat.message by overriding model", async () => { @@ -138,4 +141,92 @@ describe("model fallback hook", () => { expect(toastCalls.length).toBe(1) expect(toastCalls[0]?.title).toBe("Model fallback") }) + + test("transforms model names for github-copilot provider via fallback chain", async () => { + //#given + const sessionID = "ses_model_fallback_ghcp" + clearPendingModelFallback(sessionID) + + const hook = createModelFallbackHook() as unknown as { + "chat.message"?: ( + input: { sessionID: string }, + output: { message: Record; parts: Array<{ type: string; text?: string }> }, + ) => Promise + } + + // Set a custom fallback chain that routes through github-copilot + setSessionFallbackChain(sessionID, [ + { providers: ["github-copilot"], model: "claude-sonnet-4-6" }, + ]) + + const set = setPendingModelFallback( + sessionID, + "Atlas (Plan Executor)", + "github-copilot", + "claude-sonnet-4-6", + ) + expect(set).toBe(true) + + const output = { + message: { + model: { providerID: "github-copilot", modelID: "claude-sonnet-4-6" }, + }, + parts: [{ type: "text", text: "continue" }], + } + + //#when + await hook["chat.message"]?.({ sessionID }, output) + + //#then — model name should be transformed from hyphen to dot notation + expect(output.message["model"]).toEqual({ + providerID: "github-copilot", + modelID: "claude-sonnet-4.6", + }) + + clearPendingModelFallback(sessionID) + }) + + test("transforms model names for google provider via fallback chain", async () => { + //#given + const sessionID = "ses_model_fallback_google" + clearPendingModelFallback(sessionID) + + const hook = createModelFallbackHook() as unknown as { + "chat.message"?: ( + input: { sessionID: string }, + output: { message: Record; parts: Array<{ type: string; text?: string }> }, + ) => Promise + } + + // Set a custom fallback chain that routes through google + setSessionFallbackChain(sessionID, [ + { providers: ["google"], model: "gemini-3-pro" }, + ]) + + const set = setPendingModelFallback( + sessionID, + "Oracle", + "google", + "gemini-3-pro", + ) + expect(set).toBe(true) + + const output = { + message: { + model: { providerID: "google", modelID: "gemini-3-pro" }, + }, + parts: [{ type: "text", text: "continue" }], + } + + //#when + await hook["chat.message"]?.({ sessionID }, output) + + //#then — model name should be transformed from gemini-3-pro to gemini-3-pro-preview + expect(output.message["model"]).toEqual({ + providerID: "google", + modelID: "gemini-3-pro-preview", + }) + + clearPendingModelFallback(sessionID) + }) }) diff --git a/src/hooks/model-fallback/hook.ts b/src/hooks/model-fallback/hook.ts index fbe9deabb..bbb01825e 100644 --- a/src/hooks/model-fallback/hook.ts +++ b/src/hooks/model-fallback/hook.ts @@ -3,6 +3,7 @@ import { getAgentConfigKey } from "../../shared/agent-display-names" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { readConnectedProvidersCache, readProviderModelsCache } from "../../shared/connected-providers-cache" import { selectFallbackProvider } from "../../shared/model-error-classifier" +import { transformModelForProvider } from "../../shared/provider-model-id-transform" import { log } from "../../shared/logger" import { getTaskToastManager } from "../../features/task-toast-manager" import type { ChatMessageInput, ChatMessageHandlerOutput } from "../../plugin/chat-message" @@ -145,7 +146,7 @@ export function getNextFallback( return { providerID, - modelID: fallback.model, + modelID: transformModelForProvider(providerID, fallback.model), variant: fallback.variant, } } diff --git a/src/hooks/preemptive-compaction.test.ts b/src/hooks/preemptive-compaction.test.ts index 279562aa6..e5d266c3d 100644 --- a/src/hooks/preemptive-compaction.test.ts +++ b/src/hooks/preemptive-compaction.test.ts @@ -414,4 +414,157 @@ describe("preemptive-compaction", () => { restoreTimeouts() } }) + + // #given first compaction succeeded and context grew again + // #when tool.execute.after runs after new high-token message + // #then should trigger compaction again (re-compaction) + it("should allow re-compaction when context grows after successful compaction", async () => { + const hook = createPreemptiveCompactionHook(ctx as never, {} as never) + const sessionID = "ses_recompact" + + // given - first compaction cycle + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(1) + + // when - new message with high tokens (context grew after compaction) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "anthropic", + modelID: "claude-sonnet-4-6", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_2" }, + { title: "", output: "test", metadata: null } + ) + + // then - summarize should fire again + expect(ctx.client.session.summarize).toHaveBeenCalledTimes(2) + }) + + // #given modelContextLimitsCache has model-specific limit (256k) + // #when tokens are above default 78% of 200k but below 78% of 256k + // #then should NOT trigger compaction + it("should use model-specific context limit from modelContextLimitsCache", async () => { + const modelContextLimitsCache = new Map() + modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) + + const hook = createPreemptiveCompactionHook(ctx as never, {} as never, { + anthropicContext1MEnabled: false, + modelContextLimitsCache, + }) + const sessionID = "ses_kimi_limit" + + // 180k total tokens — above 78% of 200k (156k) but below 78% of 256k (204k) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "opencode", + modelID: "kimi-k2.5-free", + finish: true, + tokens: { + input: 170000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).not.toHaveBeenCalled() + }) + + // #given modelContextLimitsCache has model-specific limit (256k) + // #when tokens exceed 78% of model-specific limit + // #then should trigger compaction + it("should trigger compaction at model-specific threshold", async () => { + const modelContextLimitsCache = new Map() + modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144) + + const hook = createPreemptiveCompactionHook(ctx as never, {} as never, { + anthropicContext1MEnabled: false, + modelContextLimitsCache, + }) + const sessionID = "ses_kimi_trigger" + + // 210k total — above 78% of 256k (≈204k) + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + role: "assistant", + sessionID, + providerID: "opencode", + modelID: "kimi-k2.5-free", + finish: true, + tokens: { + input: 200000, + output: 0, + reasoning: 0, + cache: { read: 10000, write: 0 }, + }, + }, + }, + }, + }) + + await hook["tool.execute.after"]( + { tool: "bash", sessionID, callID: "call_1" }, + { title: "", output: "test", metadata: null } + ) + + expect(ctx.client.session.summarize).toHaveBeenCalled() + }) }) diff --git a/src/hooks/preemptive-compaction.ts b/src/hooks/preemptive-compaction.ts index d6c9bf130..d93211fd1 100644 --- a/src/hooks/preemptive-compaction.ts +++ b/src/hooks/preemptive-compaction.ts @@ -7,6 +7,7 @@ const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 type ModelCacheStateLike = { anthropicContext1MEnabled: boolean + modelContextLimitsCache?: Map } function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number { @@ -91,10 +92,12 @@ export function createPreemptiveCompactionHook( const cached = tokenCache.get(sessionID) if (!cached) return - const actualLimit = - isAnthropicProvider(cached.providerID) - ? getAnthropicActualLimit(modelCacheState) - : DEFAULT_ACTUAL_LIMIT + const modelSpecificLimit = !isAnthropicProvider(cached.providerID) + ? modelCacheState?.modelContextLimitsCache?.get(`${cached.providerID}/${cached.modelID}`) + : undefined + const actualLimit = isAnthropicProvider(cached.providerID) + ? getAnthropicActualLimit(modelCacheState) + : modelSpecificLimit ?? DEFAULT_ACTUAL_LIMIT const lastTokens = cached.tokens const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0) @@ -164,6 +167,7 @@ export function createPreemptiveCompactionHook( modelID: info.modelID ?? "", tokens: info.tokens, }) + compactedSessions.delete(info.sessionID) } } diff --git a/src/hooks/ralph-loop/AGENTS.md b/src/hooks/ralph-loop/AGENTS.md index 7e35f4371..4f94da682 100644 --- a/src/hooks/ralph-loop/AGENTS.md +++ b/src/hooks/ralph-loop/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/ralph-loop/ — Self-Referential Dev Loop -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/read-image-resizer/hook.test.ts b/src/hooks/read-image-resizer/hook.test.ts new file mode 100644 index 000000000..0b55b885d --- /dev/null +++ b/src/hooks/read-image-resizer/hook.test.ts @@ -0,0 +1,286 @@ +/// + +import { beforeEach, describe, expect, it, mock } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" + +import type { ImageDimensions, ResizeResult } from "./types" + +const mockParseImageDimensions = mock((): ImageDimensions | null => null) +const mockCalculateTargetDimensions = mock((): ImageDimensions | null => null) +const mockResizeImage = mock(async (): Promise => null) +const mockGetSessionModel = mock((_sessionID: string) => ({ + providerID: "anthropic", + modelID: "claude-sonnet-4-6", +} as { providerID: string; modelID: string } | undefined)) + +mock.module("./image-dimensions", () => ({ + parseImageDimensions: mockParseImageDimensions, +})) + +mock.module("./image-resizer", () => ({ + calculateTargetDimensions: mockCalculateTargetDimensions, + resizeImage: mockResizeImage, +})) + +mock.module("../../shared/session-model-state", () => ({ + getSessionModel: mockGetSessionModel, +})) + +import { createReadImageResizerHook } from "./hook" + +type ToolOutput = { + title: string + output: string + metadata: unknown + attachments?: Array<{ mime: string; url: string; filename?: string }> +} + +function createMockContext(): PluginInput { + return { + client: {} as PluginInput["client"], + directory: "/test", + } as PluginInput +} + +function createInput(tool: string): { tool: string; sessionID: string; callID: string } { + return { + tool, + sessionID: "session-1", + callID: "call-1", + } +} + +describe("createReadImageResizerHook", () => { + beforeEach(() => { + mockParseImageDimensions.mockReset() + mockCalculateTargetDimensions.mockReset() + mockResizeImage.mockReset() + mockGetSessionModel.mockReset() + mockGetSessionModel.mockReturnValue({ providerID: "anthropic", modelID: "claude-sonnet-4-6" }) + }) + + it("skips non-Read tools", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Bash"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips when provider is not anthropic", async () => { + //#given + mockGetSessionModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5.3-codex" }) + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips when session model is unknown", async () => { + //#given + mockGetSessionModel.mockReturnValue(undefined) + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips Read output with no attachments", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips non-image attachments", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "application/pdf", url: "data:application/pdf;base64,AAAA", filename: "file.pdf" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("skips unsupported image mime types", async () => { + //#given + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/heic", url: "data:image/heic;base64,AAAA", filename: "photo.heic" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toBe("original output") + expect(mockParseImageDimensions).not.toHaveBeenCalled() + }) + + it("appends within-limits metadata when image is already valid", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 }) + mockCalculateTargetDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toContain("[Image Info]") + expect(output.output).toContain("within limits") + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") + expect(mockResizeImage).not.toHaveBeenCalled() + }) + + it("replaces attachment URL and appends resize metadata for oversized image", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + mockResizeImage.mockResolvedValue({ + resizedDataUrl: "data:image/png;base64,resized", + original: { width: 3000, height: 2000 }, + resized: { width: 1568, height: 1045 }, + }) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "big.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,resized") + expect(output.output).toContain("[Image Resize Info]") + expect(output.output).toContain("resized") + }) + + it("keeps original attachment URL and marks resize skipped when resize fails", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 }) + mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 }) + mockResizeImage.mockResolvedValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "fail.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old") + expect(output.output).toContain("resize skipped") + }) + + it("appends unknown-dimensions metadata when parsing fails", async () => { + //#given + mockParseImageDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "corrupt.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("Read"), output) + + //#then + expect(output.output).toContain("dimensions could not be parsed") + expect(mockCalculateTargetDimensions).not.toHaveBeenCalled() + }) + + it("fires for lowercase read tool name", async () => { + //#given + mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 }) + mockCalculateTargetDimensions.mockReturnValue(null) + + const hook = createReadImageResizerHook(createMockContext()) + const output: ToolOutput = { + title: "Read", + output: "original output", + metadata: {}, + attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }], + } + + //#when + await hook["tool.execute.after"](createInput("read"), output) + + //#then + expect(mockParseImageDimensions).toHaveBeenCalledTimes(1) + expect(output.output).toContain("within limits") + }) +}) diff --git a/src/hooks/read-image-resizer/hook.ts b/src/hooks/read-image-resizer/hook.ts new file mode 100644 index 000000000..e5a199ae8 --- /dev/null +++ b/src/hooks/read-image-resizer/hook.ts @@ -0,0 +1,197 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import type { ImageAttachment, ImageDimensions } from "./types" +import { parseImageDimensions } from "./image-dimensions" +import { calculateTargetDimensions, resizeImage } from "./image-resizer" +import { log } from "../../shared" +import { getSessionModel } from "../../shared/session-model-state" +const SUPPORTED_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]) +const TOKEN_DIVISOR = 750 +interface ResizeEntry { + filename: string + originalDims: ImageDimensions | null + resizedDims: ImageDimensions | null + status: "resized" | "within-limits" | "resize-skipped" | "unknown-dims" +} +function isReadTool(toolName: string): boolean { + return toolName.toLowerCase() === "read" +} +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null + } + return value as Record +} +function isImageAttachmentRecord( + value: Record, +): value is Record & ImageAttachment { + const filename = value.filename + return ( + typeof value.mime === "string" && + typeof value.url === "string" && + (typeof filename === "undefined" || typeof filename === "string") + ) +} +function extractImageAttachments(output: Record): ImageAttachment[] { + const attachmentsValue = output.attachments + if (!Array.isArray(attachmentsValue)) { + return [] + } + const attachments: ImageAttachment[] = [] + for (const attachmentValue of attachmentsValue) { + const attachmentRecord = asRecord(attachmentValue) + if (!attachmentRecord) { + continue + } + + const mime = attachmentRecord.mime + const url = attachmentRecord.url + if (typeof mime !== "string" || typeof url !== "string") { + continue + } + + const normalizedMime = mime.toLowerCase() + if (!SUPPORTED_IMAGE_MIMES.has(normalizedMime)) { + continue + } + + attachmentRecord.mime = normalizedMime + attachmentRecord.url = url + if (isImageAttachmentRecord(attachmentRecord)) { + attachments.push(attachmentRecord) + } + } + + return attachments +} +function calculateTokens(width: number, height: number): number { + return Math.ceil((width * height) / TOKEN_DIVISOR) +} +function formatResizeAppendix(entries: ResizeEntry[]): string { + const header = entries.some((entry) => entry.status === "resized") ? "[Image Resize Info]" : "[Image Info]" + const lines = [`\n\n${header}`] + + for (const entry of entries) { + if (entry.status === "unknown-dims" || !entry.originalDims) { + lines.push(`- ${entry.filename}: dimensions could not be parsed`) + continue + } + + const original = entry.originalDims + const originalText = `${original.width}x${original.height}` + const originalTokens = calculateTokens(original.width, original.height) + + if (entry.status === "within-limits") { + lines.push(`- ${entry.filename}: ${originalText} (within limits, tokens: ${originalTokens})`) + continue + } + + if (entry.status === "resize-skipped") { + lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + continue + } + + if (!entry.resizedDims) { + lines.push(`- ${entry.filename}: ${originalText} (resize skipped, tokens: ${originalTokens})`) + continue + } + + const resized = entry.resizedDims + const resizedText = `${resized.width}x${resized.height}` + const resizedTokens = calculateTokens(resized.width, resized.height) + lines.push( + `- ${entry.filename}: ${originalText} -> ${resizedText} (resized, tokens: ${originalTokens} -> ${resizedTokens})`, + ) + } + + return lines.join("\n") +} +function resolveFilename(attachment: ImageAttachment, index: number): string { + if (attachment.filename && attachment.filename.trim().length > 0) { + return attachment.filename + } + + return `image-${index + 1}` +} +export function createReadImageResizerHook(_ctx: PluginInput) { + return { + "tool.execute.after": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { title: string; output: string; metadata: unknown }, + ) => { + if (!isReadTool(input.tool)) { + return + } + + const sessionModel = getSessionModel(input.sessionID) + if (sessionModel?.providerID !== "anthropic") { + return + } + + if (typeof output.output !== "string") { + return + } + + const outputRecord = output as Record + const attachments = extractImageAttachments(outputRecord) + if (attachments.length === 0) { + return + } + + const entries: ResizeEntry[] = [] + for (const [index, attachment] of attachments.entries()) { + const filename = resolveFilename(attachment, index) + + try { + const originalDims = parseImageDimensions(attachment.url, attachment.mime) + if (!originalDims) { + entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" }) + continue + } + + const targetDims = calculateTargetDimensions(originalDims.width, originalDims.height) + if (!targetDims) { + entries.push({ + filename, + originalDims, + resizedDims: null, + status: "within-limits", + }) + continue + } + + const resizedResult = await resizeImage(attachment.url, attachment.mime, targetDims) + if (!resizedResult) { + entries.push({ + filename, + originalDims, + resizedDims: null, + status: "resize-skipped", + }) + continue + } + + attachment.url = resizedResult.resizedDataUrl + + entries.push({ + filename, + originalDims: resizedResult.original, + resizedDims: resizedResult.resized, + status: "resized", + }) + } catch (error) { + log("[read-image-resizer] attachment processing failed", { + error: error instanceof Error ? error.message : String(error), + filename, + }) + entries.push({ filename, originalDims: null, resizedDims: null, status: "unknown-dims" }) + } + } + + if (entries.length === 0) { + return + } + + output.output += formatResizeAppendix(entries) + }, + } +} diff --git a/src/hooks/read-image-resizer/image-dimensions.test.ts b/src/hooks/read-image-resizer/image-dimensions.test.ts new file mode 100644 index 000000000..72fa2dbb7 --- /dev/null +++ b/src/hooks/read-image-resizer/image-dimensions.test.ts @@ -0,0 +1,126 @@ +/// + +import { describe, expect, it } from "bun:test" + +import { parseImageDimensions } from "./image-dimensions" + +const PNG_1X1_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + +const GIF_1X1_DATA_URL = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + +function createPngDataUrl(width: number, height: number): string { + const buf = Buffer.alloc(33) + buf.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0) + buf.writeUInt32BE(13, 8) + buf.set([0x49, 0x48, 0x44, 0x52], 12) + buf.writeUInt32BE(width, 16) + buf.writeUInt32BE(height, 20) + return `data:image/png;base64,${buf.toString("base64")}` +} + +function createGifDataUrl(width: number, height: number): string { + const buf = Buffer.alloc(10) + buf.set([0x47, 0x49, 0x46, 0x38, 0x39, 0x61], 0) + buf.writeUInt16LE(width, 6) + buf.writeUInt16LE(height, 8) + return `data:image/gif;base64,${buf.toString("base64")}` +} + +function createLargePngDataUrl(width: number, height: number, extraBase64Chars: number): string { + const baseDataUrl = createPngDataUrl(width, height) + const base64Data = baseDataUrl.slice(baseDataUrl.indexOf(",") + 1) + const paddedBase64 = `${base64Data}${"A".repeat(extraBase64Chars)}` + return `data:image/png;base64,${paddedBase64}` +} + +describe("parseImageDimensions", () => { + it("parses PNG 1x1 dimensions", () => { + //#given + const dataUrl = PNG_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 1, height: 1 }) + }) + + it("parses PNG dimensions from IHDR", () => { + //#given + const dataUrl = createPngDataUrl(3000, 2000) + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 3000, height: 2000 }) + }) + + it("parses PNG dimensions from a very large base64 payload", () => { + //#given + const dataUrl = createLargePngDataUrl(4096, 2160, 10 * 1024 * 1024) + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toEqual({ width: 4096, height: 2160 }) + }) + + it("parses GIF 1x1 dimensions", () => { + //#given + const dataUrl = GIF_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/gif") + + //#then + expect(result).toEqual({ width: 1, height: 1 }) + }) + + it("parses GIF dimensions from logical screen descriptor", () => { + //#given + const dataUrl = createGifDataUrl(320, 240) + + //#when + const result = parseImageDimensions(dataUrl, "image/gif") + + //#then + expect(result).toEqual({ width: 320, height: 240 }) + }) + + it("returns null for empty input", () => { + //#given + const dataUrl = "" + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toBeNull() + }) + + it("returns null for too-short PNG buffer", () => { + //#given + const dataUrl = "data:image/png;base64,AAAA" + + //#when + const result = parseImageDimensions(dataUrl, "image/png") + + //#then + expect(result).toBeNull() + }) + + it("returns null for unsupported mime type", () => { + //#given + const dataUrl = PNG_1X1_DATA_URL + + //#when + const result = parseImageDimensions(dataUrl, "image/heic") + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/hooks/read-image-resizer/image-dimensions.ts b/src/hooks/read-image-resizer/image-dimensions.ts new file mode 100644 index 000000000..0bb411905 --- /dev/null +++ b/src/hooks/read-image-resizer/image-dimensions.ts @@ -0,0 +1,191 @@ +import type { ImageDimensions } from "./types" + +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" + +const HEADER_BYTES = 32_768 +const HEADER_BASE64_CHARS = Math.ceil(HEADER_BYTES / 3) * 4 + +function toImageDimensions(width: number, height: number): ImageDimensions | null { + if (!Number.isFinite(width) || !Number.isFinite(height)) { + return null + } + + if (width <= 0 || height <= 0) { + return null + } + + return { width, height } +} + +function parsePngDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 24) { + return null + } + + const isPngSignature = + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + + if (!isPngSignature || buffer.toString("ascii", 12, 16) !== "IHDR") { + return null + } + + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + return toImageDimensions(width, height) +} + +function parseGifDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 10) { + return null + } + + if (buffer.toString("ascii", 0, 4) !== "GIF8") { + return null + } + + const width = buffer.readUInt16LE(6) + const height = buffer.readUInt16LE(8) + return toImageDimensions(width, height) +} + +function parseJpegDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 4 || buffer[0] !== 0xff || buffer[1] !== 0xd8) { + return null + } + + let offset = 2 + + while (offset < buffer.length) { + if (buffer[offset] !== 0xff) { + offset += 1 + continue + } + + while (offset < buffer.length && buffer[offset] === 0xff) { + offset += 1 + } + + if (offset >= buffer.length) { + return null + } + + const marker = buffer[offset] + offset += 1 + + if (marker === 0xd9 || marker === 0xda) { + break + } + + if (offset + 1 >= buffer.length) { + return null + } + + const segmentLength = buffer.readUInt16BE(offset) + if (segmentLength < 2) { + return null + } + + if ((marker === 0xc0 || marker === 0xc2) && offset + 7 < buffer.length) { + const height = buffer.readUInt16BE(offset + 3) + const width = buffer.readUInt16BE(offset + 5) + return toImageDimensions(width, height) + } + + offset += segmentLength + } + + return null +} + +function readUInt24LE(buffer: Buffer, offset: number): number { + return buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16) +} + +function parseWebpDimensions(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 16) { + return null + } + + if (buffer.toString("ascii", 0, 4) !== "RIFF" || buffer.toString("ascii", 8, 12) !== "WEBP") { + return null + } + + const chunkType = buffer.toString("ascii", 12, 16) + + if (chunkType === "VP8 ") { + if (buffer[23] !== 0x9d || buffer[24] !== 0x01 || buffer[25] !== 0x2a) { + return null + } + + const width = buffer.readUInt16LE(26) & 0x3fff + const height = buffer.readUInt16LE(28) & 0x3fff + return toImageDimensions(width, height) + } + + if (chunkType === "VP8L") { + if (buffer.length < 25 || buffer[20] !== 0x2f) { + return null + } + + const bits = buffer.readUInt32LE(21) + const width = (bits & 0x3fff) + 1 + const height = ((bits >>> 14) & 0x3fff) + 1 + return toImageDimensions(width, height) + } + + if (chunkType === "VP8X") { + const width = readUInt24LE(buffer, 24) + 1 + const height = readUInt24LE(buffer, 27) + 1 + return toImageDimensions(width, height) + } + + return null +} + +export function parseImageDimensions(base64DataUrl: string, mimeType: string): ImageDimensions | null { + try { + if (!base64DataUrl || !mimeType) { + return null + } + + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const headerBase64 = rawBase64.length > HEADER_BASE64_CHARS ? rawBase64.slice(0, HEADER_BASE64_CHARS) : rawBase64 + const buffer = Buffer.from(headerBase64, "base64") + if (buffer.length === 0) { + return null + } + + const normalizedMime = mimeType.toLowerCase() + + if (normalizedMime === "image/png") { + return parsePngDimensions(buffer) + } + + if (normalizedMime === "image/gif") { + return parseGifDimensions(buffer) + } + + if (normalizedMime === "image/jpeg" || normalizedMime === "image/jpg") { + return parseJpegDimensions(buffer) + } + + if (normalizedMime === "image/webp") { + return parseWebpDimensions(buffer) + } + + return null + } catch { + return null + } +} diff --git a/src/hooks/read-image-resizer/image-resizer.test.ts b/src/hooks/read-image-resizer/image-resizer.test.ts new file mode 100644 index 000000000..a885932b3 --- /dev/null +++ b/src/hooks/read-image-resizer/image-resizer.test.ts @@ -0,0 +1,132 @@ +/// + +import { afterEach, describe, expect, it, mock } from "bun:test" + +const PNG_1X1_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + +type ImageResizerModule = typeof import("./image-resizer") + +async function importFreshImageResizerModule(): Promise { + return import(`./image-resizer?test-${Date.now()}-${Math.random()}`) +} + +describe("calculateTargetDimensions", () => { + it("returns null when dimensions are already within limits", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(800, 600) + + //#then + expect(result).toBeNull() + }) + + it("returns null at exact long-edge boundary", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(1568, 1000) + + //#then + expect(result).toBeNull() + }) + + it("scales landscape dimensions by max long edge", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(3000, 2000) + + //#then + expect(result).toEqual({ + width: 1568, + height: Math.floor(2000 * (1568 / 3000)), + }) + }) + + it("scales portrait dimensions by max long edge", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(2000, 3000) + + //#then + expect(result).toEqual({ + width: Math.floor(2000 * (1568 / 3000)), + height: 1568, + }) + }) + + it("scales square dimensions to exact target", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(4000, 4000) + + //#then + expect(result).toEqual({ width: 1568, height: 1568 }) + }) + + it("uses custom maxLongEdge when provided", async () => { + //#given + const { calculateTargetDimensions } = await importFreshImageResizerModule() + + //#when + const result = calculateTargetDimensions(2000, 1000, 1000) + + //#then + expect(result).toEqual({ width: 1000, height: 500 }) + }) +}) + +describe("resizeImage", () => { + afterEach(() => { + mock.restore() + }) + + it("returns null when sharp import fails", async () => { + //#given + mock.module("sharp", () => { + throw new Error("sharp unavailable") + }) + const { resizeImage } = await importFreshImageResizerModule() + + //#when + const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + width: 1, + height: 1, + }) + + //#then + expect(result).toBeNull() + }) + + it("returns null when sharp throws during resize", async () => { + //#given + const mockSharpFactory = mock(() => ({ + resize: () => { + throw new Error("resize failed") + }, + })) + + mock.module("sharp", () => ({ + default: mockSharpFactory, + })) + const { resizeImage } = await importFreshImageResizerModule() + + //#when + const result = await resizeImage(PNG_1X1_DATA_URL, "image/png", { + width: 1, + height: 1, + }) + + //#then + expect(result).toBeNull() + }) +}) diff --git a/src/hooks/read-image-resizer/image-resizer.ts b/src/hooks/read-image-resizer/image-resizer.ts new file mode 100644 index 000000000..7ced5a9e8 --- /dev/null +++ b/src/hooks/read-image-resizer/image-resizer.ts @@ -0,0 +1,184 @@ +import type { ImageDimensions, ResizeResult } from "./types" +import { extractBase64Data } from "../../tools/look-at/mime-type-inference" +import { log } from "../../shared" + +const ANTHROPIC_MAX_LONG_EDGE = 1568 +const ANTHROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024 + +type SharpFormat = "jpeg" | "png" | "gif" | "webp" + +interface SharpMetadata { + width?: number + height?: number +} + +interface SharpInstance { + resize(width: number, height: number, options: { fit: "inside" }): SharpInstance + toFormat(format: SharpFormat, options?: { quality?: number }): SharpInstance + toBuffer(): Promise + metadata(): Promise +} + +type SharpFactory = (input: Buffer) => SharpInstance + +function resolveSharpFactory(sharpModule: unknown): SharpFactory | null { + if (typeof sharpModule === "function") { + return sharpModule as SharpFactory + } + + if (!sharpModule || typeof sharpModule !== "object") { + return null + } + + const defaultExport = Reflect.get(sharpModule, "default") + return typeof defaultExport === "function" ? (defaultExport as SharpFactory) : null +} + +function resolveSharpFormat(mimeType: string): SharpFormat { + const normalizedMime = mimeType.toLowerCase() + if (normalizedMime === "image/png") { + return "png" + } + if (normalizedMime === "image/gif") { + return "gif" + } + if (normalizedMime === "image/webp") { + return "webp" + } + return "jpeg" +} + +function canAdjustQuality(format: SharpFormat): boolean { + return format === "jpeg" || format === "webp" +} + +function toDimensions(metadata: SharpMetadata): ImageDimensions | null { + const { width, height } = metadata + if (!width || !height) { + return null + } + return { width, height } +} + +async function renderResizedBuffer(args: { + sharpFactory: SharpFactory + inputBuffer: Buffer + target: ImageDimensions + format: SharpFormat + quality?: number +}): Promise { + const { sharpFactory, inputBuffer, target, format, quality } = args + return sharpFactory(inputBuffer) + .resize(target.width, target.height, { fit: "inside" }) + .toFormat(format, quality ? { quality } : undefined) + .toBuffer() +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +export function calculateTargetDimensions( + width: number, + height: number, + maxLongEdge = ANTHROPIC_MAX_LONG_EDGE, +): ImageDimensions | null { + if (width <= 0 || height <= 0 || maxLongEdge <= 0) { + return null + } + + const longEdge = Math.max(width, height) + if (longEdge <= maxLongEdge) { + return null + } + + if (width >= height) { + return { + width: maxLongEdge, + height: Math.max(1, Math.floor((height * maxLongEdge) / width)), + } + } + + return { + width: Math.max(1, Math.floor((width * maxLongEdge) / height)), + height: maxLongEdge, + } +} + +export async function resizeImage( + base64DataUrl: string, + mimeType: string, + target: ImageDimensions, +): Promise { + try { + const sharpModuleName = "sharp" + const sharpModule = await import(sharpModuleName).catch(() => null) + if (!sharpModule) { + log("[read-image-resizer] sharp unavailable, skipping resize") + return null + } + + const sharpFactory = resolveSharpFactory(sharpModule) + if (!sharpFactory) { + log("[read-image-resizer] sharp import has unexpected shape") + return null + } + + const rawBase64 = extractBase64Data(base64DataUrl) + if (!rawBase64) { + return null + } + + const inputBuffer = Buffer.from(rawBase64, "base64") + if (inputBuffer.length === 0) { + return null + } + + const original = toDimensions(await sharpFactory(inputBuffer).metadata()) + if (!original) { + return null + } + + const format = resolveSharpFormat(mimeType) + let resizedBuffer = await renderResizedBuffer({ + sharpFactory, + inputBuffer, + target, + format, + }) + + if (resizedBuffer.length > ANTHROPIC_MAX_FILE_SIZE && canAdjustQuality(format)) { + for (const quality of [80, 60, 40]) { + resizedBuffer = await renderResizedBuffer({ + sharpFactory, + inputBuffer, + target, + format, + quality, + }) + + if (resizedBuffer.length <= ANTHROPIC_MAX_FILE_SIZE) { + break + } + } + } + + const resized = toDimensions(await sharpFactory(resizedBuffer).metadata()) + if (!resized) { + return null + } + + return { + resizedDataUrl: `data:${mimeType};base64,${resizedBuffer.toString("base64")}`, + original, + resized, + } + } catch (error) { + log("[read-image-resizer] resize failed", { + error: getErrorMessage(error), + mimeType, + target, + }) + return null + } +} diff --git a/src/hooks/read-image-resizer/index.ts b/src/hooks/read-image-resizer/index.ts new file mode 100644 index 000000000..d6fbcc25b --- /dev/null +++ b/src/hooks/read-image-resizer/index.ts @@ -0,0 +1 @@ +export { createReadImageResizerHook } from "./hook" diff --git a/src/hooks/read-image-resizer/types.ts b/src/hooks/read-image-resizer/types.ts new file mode 100644 index 000000000..4b6a7b05c --- /dev/null +++ b/src/hooks/read-image-resizer/types.ts @@ -0,0 +1,16 @@ +export interface ImageDimensions { + width: number + height: number +} + +export interface ImageAttachment { + mime: string + url: string + filename?: string +} + +export interface ResizeResult { + resizedDataUrl: string + original: ImageDimensions + resized: ImageDimensions +} diff --git a/src/hooks/rules-injector/AGENTS.md b/src/hooks/rules-injector/AGENTS.md index c43c66b3e..f3767e222 100644 --- a/src/hooks/rules-injector/AGENTS.md +++ b/src/hooks/rules-injector/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/rules-injector/ — Conditional Rules Injection -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/session-notification-sender.ts b/src/hooks/session-notification-sender.ts index 8c5cf1df7..4ac0c4d8c 100644 --- a/src/hooks/session-notification-sender.ts +++ b/src/hooks/session-notification-sender.ts @@ -7,6 +7,7 @@ import { getAfplayPath, getPaplayPath, getAplayPath, + getTerminalNotifierPath, } from "./session-notification-utils" import { buildWindowsToastScript, escapeAppleScriptText, escapePowerShellSingleQuotedText } from "./session-notification-formatting" @@ -39,6 +40,19 @@ export async function sendSessionNotification( ): Promise { switch (platform) { case "darwin": { + // Try terminal-notifier first — deterministic click-to-focus + const terminalNotifierPath = await getTerminalNotifierPath() + if (terminalNotifierPath) { + const bundleId = process.env.__CFBundleIdentifier + const args = [terminalNotifierPath, "-title", title, "-message", message] + if (bundleId) { + args.push("-activate", bundleId) + } + await ctx.$`${args}`.catch(() => {}) + break + } + + // Fallback: osascript (click may open Finder instead of terminal) const osascriptPath = await getOsascriptPath() if (!osascriptPath) return diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index 0c09fd8f8..5f9d572fb 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -32,11 +32,13 @@ export const getPowershellPath = createCommandFinder("powershell") export const getAfplayPath = createCommandFinder("afplay") export const getPaplayPath = createCommandFinder("paplay") export const getAplayPath = createCommandFinder("aplay") +export const getTerminalNotifierPath = createCommandFinder("terminal-notifier") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { getOsascriptPath().catch(() => {}) getAfplayPath().catch(() => {}) + getTerminalNotifierPath().catch(() => {}) } else if (platform === "linux") { getNotifySendPath().catch(() => {}) getPaplayPath().catch(() => {}) diff --git a/src/hooks/session-notification.test.ts b/src/hooks/session-notification.test.ts index cf895ba98..9d9c4706b 100644 --- a/src/hooks/session-notification.test.ts +++ b/src/hooks/session-notification.test.ts @@ -365,4 +365,87 @@ describe("session-notification", () => { // then - only one notification should be sent expect(notificationCalls).toHaveLength(1) }) + + function createSenderMockCtx() { + const notifyCalls: string[] = [] + const mockCtx = { + $: async (cmd: TemplateStringsArray | string, ...values: any[]) => { + const cmdStr = typeof cmd === "string" + ? cmd + : cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "") + notifyCalls.push(cmdStr) + return { stdout: "", stderr: "", exitCode: 0 } + }, + } as any + return { mockCtx, notifyCalls } + } + + test("should use terminal-notifier with -activate when available on darwin", async () => { + // given - terminal-notifier is available and __CFBundleIdentifier is set + spyOn(sender, "sendSessionNotification").mockRestore() + const { mockCtx, notifyCalls } = createSenderMockCtx() + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + process.env.__CFBundleIdentifier = "com.mitchellh.ghostty" + + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses terminal-notifier with -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).toContain("-activate") + expect(tnCall).toContain("com.mitchellh.ghostty") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } else { + delete process.env.__CFBundleIdentifier + } + } + }) + + test("should fall back to osascript when terminal-notifier is not available", async () => { + // given - terminal-notifier is NOT available + spyOn(sender, "sendSessionNotification").mockRestore() + const { mockCtx, notifyCalls } = createSenderMockCtx() + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue(null) + spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript") + + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - notification uses osascript (fallback) + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const osascriptCall = notifyCalls.find(c => c.includes("osascript")) + expect(osascriptCall).toBeDefined() + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeUndefined() + }) + + test("should use terminal-notifier without -activate when __CFBundleIdentifier is not set", async () => { + // given - terminal-notifier available but no bundle ID + spyOn(sender, "sendSessionNotification").mockRestore() + const { mockCtx, notifyCalls } = createSenderMockCtx() + spyOn(utils, "getTerminalNotifierPath").mockResolvedValue("/usr/local/bin/terminal-notifier") + const originalEnv = process.env.__CFBundleIdentifier + delete process.env.__CFBundleIdentifier + + try { + // when - sendSessionNotification is called directly on darwin + await sender.sendSessionNotification(mockCtx, "darwin", "Test Title", "Test Message") + + // then - terminal-notifier used but without -activate flag + expect(notifyCalls.length).toBeGreaterThanOrEqual(1) + const tnCall = notifyCalls.find(c => c.includes("terminal-notifier")) + expect(tnCall).toBeDefined() + expect(tnCall).not.toContain("-activate") + } finally { + if (originalEnv !== undefined) { + process.env.__CFBundleIdentifier = originalEnv + } + } + }) }) diff --git a/src/hooks/session-recovery/AGENTS.md b/src/hooks/session-recovery/AGENTS.md index 3959b41c7..ecd43ae61 100644 --- a/src/hooks/session-recovery/AGENTS.md +++ b/src/hooks/session-recovery/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/session-recovery/ — Auto Session Error Recovery -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/hooks/think-mode/switcher.ts b/src/hooks/think-mode/switcher.ts index 0a1a1dd38..7712df759 100644 --- a/src/hooks/think-mode/switcher.ts +++ b/src/hooks/think-mode/switcher.ts @@ -16,6 +16,8 @@ * inconsistencies defensively while maintaining backwards compatibility. */ +import { normalizeModelID } from "../../shared" + /** * Extracts provider-specific prefix from model ID (if present). * Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/). @@ -36,24 +38,6 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } { } } -/** - * Normalizes model IDs to use consistent hyphen formatting. - * GitHub Copilot may use dots (claude-opus-4.6) but our maps use hyphens (claude-opus-4-6). - * This ensures lookups work regardless of format. - * - * @example - * normalizeModelID("claude-opus-4.6") // "claude-opus-4-6" - * normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro" - * normalizeModelID("gpt-5.2") // "gpt-5-2" - * normalizeModelID("vertex_ai/claude-opus-4.6") // "vertex_ai/claude-opus-4-6" - */ -function normalizeModelID(modelID: string): string { - // Replace dots with hyphens when followed by a digit - // This handles version numbers like 4.5 → 4-5, 5.2 → 5-2 - return modelID.replace(/\.(\d+)/g, "-$1") -} - - // Maps model IDs to their "high reasoning" variant (internal convention) // For OpenAI models, this signals that reasoning_effort should be set to "high" diff --git a/src/hooks/todo-continuation-enforcer/AGENTS.md b/src/hooks/todo-continuation-enforcer/AGENTS.md index 6f0166b66..132a16bfd 100644 --- a/src/hooks/todo-continuation-enforcer/AGENTS.md +++ b/src/hooks/todo-continuation-enforcer/AGENTS.md @@ -1,6 +1,6 @@ # src/hooks/todo-continuation-enforcer/ — Boulder Continuation Mechanism -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/index.ts b/src/index.ts index bba719041..3ae22411c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { initConfigContext } from "./cli/config-manager/config-context" import type { Plugin } from "@opencode-ai/plugin" import type { HookName } from "./config" @@ -14,6 +15,8 @@ import { injectServerAuthIntoClient, log } from "./shared" import { startTmuxCheck } from "./tools" const OhMyOpenCodePlugin: Plugin = async (ctx) => { + // Initialize config context for plugin runtime (prevents warnings from hooks) + initConfigContext("opencode", null) log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory, }) diff --git a/src/mcp/AGENTS.md b/src/mcp/AGENTS.md index cb2304806..9c728114e 100644 --- a/src/mcp/AGENTS.md +++ b/src/mcp/AGENTS.md @@ -1,6 +1,6 @@ # src/mcp/ — 3 Built-in Remote MCPs -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 1802f00c8..c1bc3441b 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { mergeConfigs, parseConfigPartially } from "./plugin-config"; +import { + detectLikelyBuiltinAgentTypos, + detectUnknownBuiltinAgentKeys, + mergeConfigs, + parseConfigPartially, +} from "./plugin-config"; import type { OhMyOpenCodeConfig } from "./config"; describe("mergeConfigs", () => { @@ -115,6 +120,27 @@ describe("mergeConfigs", () => { expect(result.disabled_hooks).toContain("session-recovery"); expect(result.disabled_hooks?.length).toBe(3); }); + + it("should deep merge custom_agents", () => { + const base: OhMyOpenCodeConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const override: OhMyOpenCodeConfig = { + custom_agents: { + translator: { temperature: 0 }, + "database-architect": { model: "openai/gpt-5.3-codex" }, + }, + } + + const result = mergeConfigs(base, override) + + expect(result.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview") + expect(result.custom_agents?.translator?.temperature).toBe(0) + expect(result.custom_agents?.["database-architect"]?.model).toBe("openai/gpt-5.3-codex") + }) }); }); @@ -165,7 +191,9 @@ describe("parseConfigPartially", () => { expect(result).not.toBeNull(); expect(result!.disabled_hooks).toEqual(["comment-checker"]); - expect(result!.agents).toBeUndefined(); + expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); + expect(result!.agents?.momus?.model).toBe("openai/gpt-5.2"); + expect((result!.agents as Record)?.prometheus).toBeUndefined(); }); it("should preserve valid agents when a non-agent section is invalid", () => { @@ -182,6 +210,36 @@ describe("parseConfigPartially", () => { expect(result!.agents?.oracle?.model).toBe("openai/gpt-5.2"); expect(result!.disabled_hooks).toEqual(["not-a-real-hook"]); }); + + it("should preserve valid built-in agent entries when agents contains unknown keys", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.3-codex" }, + sisyphuss: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.agents?.sisyphus?.model).toBe("openai/gpt-5.3-codex"); + expect((result!.agents as Record)?.sisyphuss).toBeUndefined(); + }); + + it("should preserve valid custom_agents entries when custom_agents contains reserved names", () => { + const rawConfig = { + custom_agents: { + translator: { model: "google/gemini-3-flash-preview" }, + sisyphus: { model: "openai/gpt-5.3-codex" }, + }, + }; + + const result = parseConfigPartially(rawConfig); + + expect(result).not.toBeNull(); + expect(result!.custom_agents?.translator?.model).toBe("google/gemini-3-flash-preview"); + expect((result!.custom_agents as Record)?.sisyphus).toBeUndefined(); + }); }); describe("completely invalid config", () => { @@ -237,3 +295,105 @@ describe("parseConfigPartially", () => { }); }); }); + +describe("detectLikelyBuiltinAgentTypos", () => { + it("detects near-miss builtin agent keys", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "sisyphuss", + suggestion: "sisyphus", + }, + ]) + }) + + it("suggests canonical key casing for OpenCode-Builder typos", () => { + const rawConfig = { + agents: { + "opencode-buildr": { model: "openai/gpt-5.2" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([ + { + key: "opencode-buildr", + suggestion: "OpenCode-Builder", + }, + ]) + }) + + it("does not flag valid custom agent names", () => { + const rawConfig = { + agents: { + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const warnings = detectLikelyBuiltinAgentTypos(rawConfig) + + expect(warnings).toEqual([]) + }) +}) + +describe("detectUnknownBuiltinAgentKeys", () => { + it("returns unknown keys under agents", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("returns empty array when all keys are built-ins", () => { + const rawConfig = { + agents: { + sisyphus: { model: "openai/gpt-5.2" }, + prometheus: { model: "openai/gpt-5.2" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig) + + expect(unknownKeys).toEqual([]) + }) + + it("excludes typo keys when explicitly provided", () => { + const rawConfig = { + agents: { + sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) + + it("excludes typo keys case-insensitively", () => { + const rawConfig = { + agents: { + Sisyphuss: { model: "openai/gpt-5.2" }, + translator: { model: "google/gemini-3-flash-preview" }, + }, + } + + const unknownKeys = detectUnknownBuiltinAgentKeys(rawConfig, ["sisyphuss"]) + + expect(unknownKeys).toEqual(["translator"]) + }) +}) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index fa22c5b3c..c480f9848 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,6 +1,10 @@ import * as fs from "fs"; import * as path from "path"; -import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; +import { + OhMyOpenCodeConfigSchema, + OverridableAgentNameSchema, + type OhMyOpenCodeConfig, +} from "./config"; import { log, deepMerge, @@ -11,6 +15,90 @@ import { migrateConfigFile, } from "./shared"; +const BUILTIN_AGENT_OVERRIDE_KEYS = OverridableAgentNameSchema.options; +const BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER = new Map( + BUILTIN_AGENT_OVERRIDE_KEYS.map((key) => [key.toLowerCase(), key]), +); + +function levenshteinDistance(a: string, b: string): number { + const rows = a.length + 1; + const cols = b.length + 1; + const matrix: number[][] = Array.from({ length: rows }, () => Array(cols).fill(0)); + + for (let i = 0; i < rows; i += 1) matrix[i][0] = i; + for (let j = 0; j < cols; j += 1) matrix[0][j] = j; + + for (let i = 1; i < rows; i += 1) { + for (let j = 1; j < cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j - 1] + cost, + ); + } + } + + return matrix[rows - 1][cols - 1]; +} + +type AgentTypoWarning = { + key: string; + suggestion: string; +}; + +export function detectLikelyBuiltinAgentTypos( + rawConfig: Record, +): AgentTypoWarning[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + const warnings: AgentTypoWarning[] = []; + for (const key of Object.keys(agents)) { + const lowerKey = key.toLowerCase(); + if (BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lowerKey)) { + continue; + } + + let bestMatchLower: string | undefined; + let bestDistance = Number.POSITIVE_INFINITY; + for (const builtinKey of BUILTIN_AGENT_OVERRIDE_KEYS) { + const distance = levenshteinDistance(lowerKey, builtinKey.toLowerCase()); + if (distance < bestDistance) { + bestDistance = distance; + bestMatchLower = builtinKey.toLowerCase(); + } + } + + if (bestMatchLower && bestDistance <= 2) { + const suggestion = BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.get(bestMatchLower) ?? bestMatchLower; + warnings.push({ key, suggestion }); + } + } + + return warnings; +} + +export function detectUnknownBuiltinAgentKeys( + rawConfig: Record, + excludeKeys: string[] = [], +): string[] { + const agents = rawConfig.agents; + if (!agents || typeof agents !== "object") return []; + + const excluded = new Set(excludeKeys.map((key) => key.toLowerCase())); + + return Object.keys(agents).filter( + (key) => { + const lower = key.toLowerCase(); + return ( + !BUILTIN_AGENT_OVERRIDE_KEYS_BY_LOWER.has(lower) + && !excluded.has(lower) + ); + }, + ); +} + export function parseConfigPartially( rawConfig: Record ): OhMyOpenCodeConfig | null { @@ -22,7 +110,52 @@ export function parseConfigPartially( const partialConfig: Record = {}; const invalidSections: string[] = []; + const parseAgentSectionEntries = (sectionKey: "agents" | "custom_agents"): void => { + const rawSection = rawConfig[sectionKey]; + if (!rawSection || typeof rawSection !== "object") return; + + const parsedSection: Record = {}; + const invalidEntries: string[] = []; + + for (const [entryKey, entryValue] of Object.entries(rawSection)) { + const singleEntryResult = OhMyOpenCodeConfigSchema.safeParse({ + [sectionKey]: { [entryKey]: entryValue }, + }); + + if (singleEntryResult.success) { + const parsed = singleEntryResult.data as Record; + const parsedSectionValue = parsed[sectionKey]; + if (parsedSectionValue && typeof parsedSectionValue === "object") { + const typedSection = parsedSectionValue as Record; + if (typedSection[entryKey] !== undefined) { + parsedSection[entryKey] = typedSection[entryKey]; + } + } + continue; + } + + const entryErrors = singleEntryResult.error.issues + .map((issue) => `${entryKey}: ${issue.message}`) + .join(", "); + if (entryErrors) { + invalidEntries.push(entryErrors); + } + } + + if (Object.keys(parsedSection).length > 0) { + partialConfig[sectionKey] = parsedSection; + } + if (invalidEntries.length > 0) { + invalidSections.push(`${sectionKey}: ${invalidEntries.join(", ")}`); + } + }; + for (const key of Object.keys(rawConfig)) { + if (key === "agents" || key === "custom_agents") { + parseAgentSectionEntries(key); + continue; + } + const sectionResult = OhMyOpenCodeConfigSchema.safeParse({ [key]: rawConfig[key] }); if (sectionResult.success) { const parsed = sectionResult.data as Record; @@ -58,6 +191,32 @@ export function loadConfigFromPath( migrateConfigFile(configPath, rawConfig); + const typoWarnings = detectLikelyBuiltinAgentTypos(rawConfig); + if (typoWarnings.length > 0) { + const warningMsg = typoWarnings + .map((warning) => `agents.${warning.key} (did you mean agents.${warning.suggestion}?)`) + .join(", "); + log(`Potential agent override typos in ${configPath}: ${warningMsg}`); + addConfigLoadError({ + path: configPath, + error: `Potential agent override typos detected: ${warningMsg}`, + }); + } + + const unknownAgentKeys = detectUnknownBuiltinAgentKeys( + rawConfig, + typoWarnings.map((warning) => warning.key), + ); + if (unknownAgentKeys.length > 0) { + const unknownKeysMsg = unknownAgentKeys.map((key) => `agents.${key}`).join(", "); + const migrationHint = "Move custom entries from agents.* to custom_agents.*"; + log(`Unknown built-in agent override keys in ${configPath}: ${unknownKeysMsg}. ${migrationHint}`); + addConfigLoadError({ + path: configPath, + error: `Unknown built-in agent override keys: ${unknownKeysMsg}. ${migrationHint}`, + }); + } + const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig); if (result.success) { @@ -98,6 +257,7 @@ export function mergeConfigs( ...base, ...override, agents: deepMerge(base.agents, override.agents), + custom_agents: deepMerge(base.custom_agents, override.custom_agents), categories: deepMerge(base.categories, override.categories), disabled_agents: [ ...new Set([ @@ -170,6 +330,7 @@ export function loadPluginConfig( log("Final merged config", { agents: config.agents, + custom_agents: config.custom_agents, disabled_agents: config.disabled_agents, disabled_mcps: config.disabled_mcps, disabled_hooks: config.disabled_hooks, diff --git a/src/plugin-handlers/AGENTS.md b/src/plugin-handlers/AGENTS.md index 844242bd0..04265d5df 100644 --- a/src/plugin-handlers/AGENTS.md +++ b/src/plugin-handlers/AGENTS.md @@ -1,6 +1,6 @@ # src/plugin-handlers/ — 6-Phase Config Loading Pipeline -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/plugin-handlers/agent-config-handler.ts b/src/plugin-handlers/agent-config-handler.ts index 08230f55a..4e61b5c15 100644 --- a/src/plugin-handlers/agent-config-handler.ts +++ b/src/plugin-handlers/agent-config-handler.ts @@ -4,6 +4,7 @@ import type { OhMyOpenCodeConfig } from "../config"; import { log, migrateAgentConfig } from "../shared"; import { AGENT_NAME_MAP } from "../shared/migration"; import { getAgentDisplayName } from "../shared/agent-display-names"; +import { mergeCategories } from "../shared/merge-categories"; import { discoverConfigSourceSkills, discoverOpencodeGlobalSkills, @@ -17,6 +18,13 @@ import { reorderAgentsByPriority } from "./agent-priority-order"; import { remapAgentKeysToDisplayNames } from "./agent-key-remapper"; import { buildPrometheusAgentConfig } from "./prometheus-agent-config-builder"; import { buildPlanDemoteConfig } from "./plan-model-inheritance"; +import { + applyCustomAgentOverrides, + collectCustomAgentSummariesFromRecord, + mergeCustomAgentSummaries, + collectKnownCustomAgentNames, + filterSummariesByKnownNames, +} from "./custom-agent-utils"; type AgentConfigRecord = Record | undefined> & { build?: Record; @@ -74,26 +82,19 @@ export async function applyAgentConfig(params: { const browserProvider = params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; const currentModel = params.config.model as string | undefined; + const disabledAgentNames = new Set( + (migratedDisabledAgents ?? []).map((agent) => agent.toLowerCase()), + ); + const filterDisabledAgents = (agents: Record) => + Object.fromEntries( + Object.entries(agents).filter( + ([name]) => !disabledAgentNames.has(name.toLowerCase()), + ), + ); const disabledSkills = new Set(params.pluginConfig.disabled_skills ?? []); const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; - const builtinAgents = await createBuiltinAgents( - migratedDisabledAgents, - params.pluginConfig.agents, - params.ctx.directory, - currentModel, - params.pluginConfig.categories, - params.pluginConfig.git_master, - allDiscoveredSkills, - params.ctx.client, - browserProvider, - currentModel, - disabledSkills, - useTaskSystem, - disableOmoEnv, - ); - const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; const userAgents = includeClaudeAgents ? loadUserAgents() : {}; const projectAgents = includeClaudeAgents ? loadProjectAgents(params.ctx.directory) : {}; @@ -106,15 +107,49 @@ export async function applyAgentConfig(params: { ]), ); - const disabledAgentNames = new Set( - (migratedDisabledAgents ?? []).map(a => a.toLowerCase()) + const configAgent = params.config.agent as AgentConfigRecord | undefined; + const filteredUserAgents = filterDisabledAgents(userAgents as Record); + const filteredProjectAgents = filterDisabledAgents(projectAgents as Record); + const filteredPluginAgents = filterDisabledAgents(pluginAgents as Record); + const filteredConfigAgentsForSummary = filterDisabledAgents( + (configAgent as Record | undefined) ?? {}, ); + const mergedCategories = mergeCategories(params.pluginConfig.categories) + const knownCustomAgentNames = collectKnownCustomAgentNames( + filteredUserAgents, + filteredProjectAgents, + filteredPluginAgents, + filteredConfigAgentsForSummary, + ) - const filterDisabledAgents = (agents: Record) => - Object.fromEntries( - Object.entries(agents).filter(([name]) => !disabledAgentNames.has(name.toLowerCase())) - ); + const customAgentSummaries = mergeCustomAgentSummaries( + collectCustomAgentSummariesFromRecord(filteredUserAgents), + collectCustomAgentSummariesFromRecord(filteredProjectAgents), + collectCustomAgentSummariesFromRecord(filteredPluginAgents), + collectCustomAgentSummariesFromRecord(filteredConfigAgentsForSummary), + filterSummariesByKnownNames( + collectCustomAgentSummariesFromRecord( + params.pluginConfig.custom_agents as Record | undefined, + ), + knownCustomAgentNames, + ), + ) + const builtinAgents = await createBuiltinAgents( + migratedDisabledAgents, + params.pluginConfig.agents, + params.ctx.directory, + currentModel, + params.pluginConfig.categories, + params.pluginConfig.git_master, + allDiscoveredSkills, + customAgentSummaries, + browserProvider, + currentModel, + disabledSkills, + useTaskSystem, + disableOmoEnv, + ); const isSisyphusEnabled = params.pluginConfig.sisyphus_agent?.disabled !== true; const builderEnabled = params.pluginConfig.sisyphus_agent?.default_builder_enabled ?? false; @@ -123,8 +158,6 @@ export async function applyAgentConfig(params: { const shouldDemotePlan = plannerEnabled && replacePlan; const configuredDefaultAgent = getConfiguredDefaultAgent(params.config); - const configAgent = params.config.agent as AgentConfigRecord | undefined; - if (isSisyphusEnabled && builtinAgents.sisyphus) { if (configuredDefaultAgent) { (params.config as { default_agent?: string }).default_agent = @@ -168,6 +201,7 @@ export async function applyAgentConfig(params: { pluginPrometheusOverride: prometheusOverride, userCategories: params.pluginConfig.categories, currentModel, + customAgentSummaries, }); } @@ -203,9 +237,9 @@ export async function applyAgentConfig(params: { ...Object.fromEntries( Object.entries(builtinAgents).filter(([key]) => key !== "sisyphus"), ), - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...filteredConfigAgents, build: { ...migratedBuild, mode: "subagent", hidden: true }, ...(planDemoteConfig ? { plan: planDemoteConfig } : {}), @@ -213,13 +247,31 @@ export async function applyAgentConfig(params: { } else { params.config.agent = { ...builtinAgents, - ...filterDisabledAgents(userAgents), - ...filterDisabledAgents(projectAgents), - ...filterDisabledAgents(pluginAgents), + ...filteredUserAgents, + ...filteredProjectAgents, + ...filteredPluginAgents, ...configAgent, }; } + if (params.config.agent) { + const builtinOverrideKeys = new Set([ + ...Object.keys(builtinAgents).map((key) => key.toLowerCase()), + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ]) + + applyCustomAgentOverrides({ + mergedAgents: params.config.agent as Record, + userOverrides: params.pluginConfig.custom_agents, + builtinOverrideKeys, + mergedCategories, + directory: params.ctx.directory, + }) + } + if (params.config.agent) { params.config.agent = remapAgentKeysToDisplayNames( params.config.agent as Record, diff --git a/src/plugin-handlers/config-handler.test.ts b/src/plugin-handlers/config-handler.test.ts index 7b735afe4..3dbe54f4b 100644 --- a/src/plugin-handlers/config-handler.test.ts +++ b/src/plugin-handlers/config-handler.test.ts @@ -162,6 +162,347 @@ describe("Sisyphus-Junior model inheritance", () => { }) }) +describe("custom agent overrides", () => { + test("passes custom agent summaries into builtin agent prompt builder", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize text", + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + expect(firstCallArgs).toBeDefined() + expect(Array.isArray(firstCallArgs[7])).toBe(true) + expect(firstCallArgs[7]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "translator", + description: "Translate and localize text", + }), + ]), + ) + }) + + test("applies oh-my-opencode agent overrides to custom Claude agents", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "(user) translator", + prompt: "Base translator prompt", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + model: "google/gemini-3-flash-preview", + temperature: 0, + prompt_append: "Always preserve placeholders exactly.", + }, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentConfig = config.agent as Record + expect(agentConfig.translator).toBeDefined() + expect(agentConfig.translator.model).toBe("google/gemini-3-flash-preview") + expect(agentConfig.translator.temperature).toBe(0) + expect(agentConfig.translator.prompt).toContain("Base translator prompt") + expect(agentConfig.translator.prompt).toContain("Always preserve placeholders exactly.") + }) + + test("prometheus prompt includes custom agent catalog for planning", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).toContain("Translate and localize locale files") + }) + + test("prometheus prompt excludes unknown custom_agents entries", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + ghostwriter: { + description: "This agent does not exist in runtime", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("translator") + expect(agentsConfig[pKey].prompt).not.toContain("ghostwriter") + }) + + test("prometheus prompt excludes disabled custom agents from catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + disabled_agents: ["translator"], + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).not.toContain("translator") + }) + + test("prometheus custom prompt override still includes custom agent catalog", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "Translate and localize locale files", + prompt: "Translate content", + }, + }) + + const pluginConfig: OhMyOpenCodeConfig = { + agents: { + prometheus: { + prompt: "Custom planner prompt", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const agentsConfig = config.agent as Record + const pKey = getAgentDisplayName("prometheus") + expect(agentsConfig[pKey]).toBeDefined() + expect(agentsConfig[pKey].prompt).toContain("Custom planner prompt") + expect(agentsConfig[pKey].prompt).toContain("") + expect(agentsConfig[pKey].prompt).toContain("translator") + }) + + test("custom agent summary merge preserves flags when custom_agents adds description", async () => { + // #given + ;(agentLoader.loadUserAgents as any).mockReturnValue({ + translator: { + name: "translator", + mode: "subagent", + description: "", + hidden: true, + disabled: true, + enabled: false, + prompt: "Translate content", + }, + }) + const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { + mock: { calls: unknown[][] } + } + + const pluginConfig: OhMyOpenCodeConfig = { + custom_agents: { + translator: { + description: "Translate and localize locale files", + }, + }, + sisyphus_agent: { + planner_enabled: true, + }, + } + const config: Record = { + model: "anthropic/claude-opus-4-6", + agent: {}, + } + + const handler = createConfigHandler({ + ctx: { directory: "/tmp" }, + pluginConfig, + modelCacheState: { + anthropicContext1MEnabled: false, + modelContextLimitsCache: new Map(), + }, + }) + + // #when + await handler(config) + + // #then + const firstCallArgs = createBuiltinAgentsMock.mock.calls[0] + const summaries = firstCallArgs[7] as Array<{ + name: string + description: string + hidden?: boolean + disabled?: boolean + enabled?: boolean + }> + const translatorSummary = summaries.find((summary) => summary.name === "translator") + + expect(translatorSummary).toBeDefined() + expect(translatorSummary?.description).toBe("Translate and localize locale files") + expect(translatorSummary?.hidden).toBe(true) + expect(translatorSummary?.disabled).toBe(true) + expect(translatorSummary?.enabled).toBe(false) + }) +}) + describe("Plan agent demote behavior", () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { // #given diff --git a/src/plugin-handlers/custom-agent-utils.ts b/src/plugin-handlers/custom-agent-utils.ts new file mode 100644 index 000000000..eb0568727 --- /dev/null +++ b/src/plugin-handlers/custom-agent-utils.ts @@ -0,0 +1,142 @@ +import type { AgentConfig } from "@opencode-ai/sdk"; +import { applyOverrides } from "../agents/builtin-agents/agent-overrides"; +import type { AgentOverrideConfig } from "../agents/types"; +import type { OhMyOpenCodeConfig } from "../config"; +import { getAgentConfigKey } from "../shared/agent-display-names"; +import { AGENT_NAME_MAP } from "../shared/migration"; +import { mergeCategories } from "../shared/merge-categories"; + +const RESERVED_AGENT_KEYS = new Set( + [ + "build", + "plan", + "sisyphus-junior", + "opencode-builder", + ...Object.keys(AGENT_NAME_MAP), + ...Object.values(AGENT_NAME_MAP), + ].map((key) => getAgentConfigKey(key).toLowerCase()), +); + +export type AgentSummary = { + name: string; + description: string; + hidden?: boolean; + disabled?: boolean; + enabled?: boolean; +}; + +export function applyCustomAgentOverrides(params: { + mergedAgents: Record; + userOverrides: OhMyOpenCodeConfig["custom_agents"] | undefined; + builtinOverrideKeys: Set; + mergedCategories: ReturnType; + directory: string; +}): void { + if (!params.userOverrides) return; + + for (const [overrideKey, override] of Object.entries(params.userOverrides)) { + if (!override) continue; + + const normalizedOverrideKey = getAgentConfigKey(overrideKey).toLowerCase(); + if (params.builtinOverrideKeys.has(normalizedOverrideKey)) continue; + + const existingKey = Object.keys(params.mergedAgents).find( + (key) => key.toLowerCase() === overrideKey.toLowerCase() || key.toLowerCase() === normalizedOverrideKey, + ); + if (!existingKey) continue; + + const existingAgent = params.mergedAgents[existingKey]; + if (!existingAgent || typeof existingAgent !== "object") continue; + + params.mergedAgents[existingKey] = applyOverrides( + existingAgent as AgentConfig, + override as AgentOverrideConfig, + params.mergedCategories, + params.directory, + ); + } +} + +export function collectCustomAgentSummariesFromRecord( + agents: Record | undefined, +): AgentSummary[] { + if (!agents) return []; + + const summaries: AgentSummary[] = []; + for (const [name, value] of Object.entries(agents)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + const agentValue = value as Record; + const description = typeof agentValue.description === "string" ? agentValue.description : ""; + + summaries.push({ + name, + description, + hidden: typeof agentValue.hidden === "boolean" ? agentValue.hidden : undefined, + disabled: typeof agentValue.disabled === "boolean" ? agentValue.disabled : undefined, + enabled: typeof agentValue.enabled === "boolean" ? agentValue.enabled : undefined, + }); + } + + return summaries; +} + +export function mergeCustomAgentSummaries(...summaryGroups: AgentSummary[][]): AgentSummary[] { + const merged = new Map(); + + for (const group of summaryGroups) { + for (const summary of group) { + const key = summary.name.toLowerCase(); + if (!merged.has(key)) { + merged.set(key, summary); + continue; + } + + const existing = merged.get(key); + if (!existing) continue; + + const existingDescription = existing.description.trim(); + const incomingDescription = summary.description.trim(); + + merged.set(key, { + ...existing, + ...summary, + hidden: summary.hidden ?? existing.hidden, + disabled: summary.disabled ?? existing.disabled, + enabled: summary.enabled ?? existing.enabled, + description: incomingDescription || existingDescription, + }); + } + } + + return Array.from(merged.values()); +} + +export function collectKnownCustomAgentNames( + ...agentGroups: Array | undefined> +): Set { + const knownNames = new Set(); + + for (const group of agentGroups) { + if (!group) continue; + + for (const [name, value] of Object.entries(group)) { + const normalizedName = getAgentConfigKey(name).toLowerCase(); + if (RESERVED_AGENT_KEYS.has(normalizedName)) continue; + if (!value || typeof value !== "object") continue; + + knownNames.add(normalizedName); + } + } + + return knownNames; +} + +export function filterSummariesByKnownNames( + summaries: AgentSummary[], + knownNames: Set, +): AgentSummary[] { + return summaries.filter((summary) => knownNames.has(summary.name.toLowerCase())); +} diff --git a/src/plugin-handlers/prometheus-agent-config-builder.ts b/src/plugin-handlers/prometheus-agent-config-builder.ts index 3c080ed10..a2d63c84a 100644 --- a/src/plugin-handlers/prometheus-agent-config-builder.ts +++ b/src/plugin-handlers/prometheus-agent-config-builder.ts @@ -1,6 +1,7 @@ import type { CategoryConfig } from "../config/schema"; import { PROMETHEUS_PERMISSION, getPrometheusPrompt } from "../agents/prometheus"; import { resolvePromptAppend } from "../agents/builtin-agents/resolve-file-uri"; +import { parseRegisteredAgentSummaries } from "../agents/custom-agent-summaries"; import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; import { fetchAvailableModels, @@ -27,6 +28,7 @@ export async function buildPrometheusAgentConfig(params: { pluginPrometheusOverride: PrometheusOverride | undefined; userCategories: Record | undefined; currentModel: string | undefined; + customAgentSummaries?: unknown; }): Promise> { const categoryConfig = params.pluginPrometheusOverride?.category ? resolveCategoryConfig(params.pluginPrometheusOverride.category, params.userCategories) @@ -65,11 +67,18 @@ export async function buildPrometheusAgentConfig(params: { const maxTokensToUse = params.pluginPrometheusOverride?.maxTokens ?? categoryConfig?.maxTokens; + const customAgentCatalog = parseRegisteredAgentSummaries(params.customAgentSummaries) + const customAgentBlock = customAgentCatalog.length > 0 + ? `\n\n\nAvailable custom agents for planning/delegation:\n${customAgentCatalog + .map((agent) => `- ${agent.name}: ${agent.description || "No description provided"}`) + .join("\n")}\n` + : "" + const base: Record = { ...(resolvedModel ? { model: resolvedModel } : {}), ...(variantToUse ? { variant: variantToUse } : {}), mode: "all", - prompt: getPrometheusPrompt(resolvedModel), + prompt: getPrometheusPrompt(resolvedModel) + customAgentBlock, permission: PROMETHEUS_PERMISSION, description: `${(params.configAgentPlan?.description as string) ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, color: (params.configAgentPlan?.color as string) ?? "#FF5722", @@ -94,5 +103,12 @@ export async function buildPrometheusAgentConfig(params: { if (prompt_append && typeof merged.prompt === "string") { merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append); } + if ( + customAgentBlock + && typeof merged.prompt === "string" + && !merged.prompt.includes("") + ) { + merged.prompt = merged.prompt + customAgentBlock; + } return merged; } diff --git a/src/plugin-handlers/tool-config-handler.ts b/src/plugin-handlers/tool-config-handler.ts index e488d2da9..381dbb55c 100644 --- a/src/plugin-handlers/tool-config-handler.ts +++ b/src/plugin-handlers/tool-config-handler.ts @@ -99,9 +99,9 @@ export function applyToolConfig(params: { } params.config.permission = { - ...(params.config.permission as Record), webfetch: "allow", external_directory: "allow", + ...(params.config.permission as Record), task: "deny", }; } diff --git a/src/plugin/AGENTS.md b/src/plugin/AGENTS.md index a3aa2024a..d751a2fd1 100644 --- a/src/plugin/AGENTS.md +++ b/src/plugin/AGENTS.md @@ -1,6 +1,6 @@ # src/plugin/ — 8 OpenCode Hook Handlers + Hook Composition -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/plugin/chat-headers.test.ts b/src/plugin/chat-headers.test.ts index f2858605d..35de5e006 100644 --- a/src/plugin/chat-headers.test.ts +++ b/src/plugin/chat-headers.test.ts @@ -106,4 +106,41 @@ describe("createChatHeadersHandler", () => { expect(output.headers["x-initiator"]).toBeUndefined() }) + + test("skips x-initiator override when model uses @ai-sdk/github-copilot", async () => { + const handler = createChatHeadersHandler({ + ctx: { + client: { + session: { + message: async () => ({ + data: { + parts: [ + { + type: "text", + text: `notification\n${OMO_INTERNAL_INITIATOR_MARKER}`, + }, + ], + }, + }), + }, + }, + } as never, + }) + const output: { headers: Record } = { headers: {} } + + await handler( + { + sessionID: "ses_4", + provider: { id: "github-copilot" }, + model: { api: { npm: "@ai-sdk/github-copilot" } }, + message: { + id: "msg_4", + role: "user", + }, + }, + output, + ) + + expect(output.headers["x-initiator"]).toBeUndefined() + }) }) diff --git a/src/plugin/chat-headers.ts b/src/plugin/chat-headers.ts index 044ccaf28..9945ac1f1 100644 --- a/src/plugin/chat-headers.ts +++ b/src/plugin/chat-headers.ts @@ -123,6 +123,17 @@ export function createChatHeadersHandler(args: { ctx: PluginContext }): (input: if (!isChatHeadersOutput(output)) return if (!isCopilotProvider(normalizedInput.provider.id)) return + + // Do not override x-initiator when @ai-sdk/github-copilot is active. + // OpenCode's copilot fetch wrapper already sets x-initiator based on + // the actual request body content. Overriding it here causes a mismatch + // that the Copilot API rejects with "invalid initiator". + const model = isRecord(input) && isRecord((input as Record).model) + ? (input as Record).model as Record + : undefined + const api = model && isRecord(model.api) ? model.api as Record : undefined + if (api?.npm === "@ai-sdk/github-copilot") return + if (!(await isOmoInternalMessage(normalizedInput, ctx.client))) return output.headers["x-initiator"] = "agent" diff --git a/src/plugin/hooks/create-continuation-hooks.ts b/src/plugin/hooks/create-continuation-hooks.ts index da453f58d..9cedce212 100644 --- a/src/plugin/hooks/create-continuation-hooks.ts +++ b/src/plugin/hooks/create-continuation-hooks.ts @@ -111,6 +111,7 @@ export function createContinuationHooks(args: { isContinuationStopped: (sessionID: string) => stopContinuationGuard?.isStopped(sessionID) ?? false, agentOverrides: pluginConfig.agents, + autoCommit: pluginConfig.start_work?.auto_commit, })) : null diff --git a/src/plugin/hooks/create-skill-hooks.ts b/src/plugin/hooks/create-skill-hooks.ts index 043a0bbbb..b0514d583 100644 --- a/src/plugin/hooks/create-skill-hooks.ts +++ b/src/plugin/hooks/create-skill-hooks.ts @@ -1,5 +1,5 @@ import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder" -import type { HookName } from "../../config" +import type { HookName, OhMyOpenCodeConfig } from "../../config" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" import type { PluginContext } from "../types" @@ -13,12 +13,20 @@ export type SkillHooks = { export function createSkillHooks(args: { ctx: PluginContext + pluginConfig: OhMyOpenCodeConfig isHookEnabled: (hookName: HookName) => boolean safeHookEnabled: boolean mergedSkills: LoadedSkill[] availableSkills: AvailableSkill[] }): SkillHooks { - const { ctx, isHookEnabled, safeHookEnabled, mergedSkills, availableSkills } = args + const { + ctx, + pluginConfig, + isHookEnabled, + safeHookEnabled, + mergedSkills, + availableSkills, + } = args const safeHook = (hookName: HookName, factory: () => T): T | null => safeCreateHook(hookName, factory, { enabled: safeHookEnabled }) @@ -30,7 +38,11 @@ export function createSkillHooks(args: { const autoSlashCommand = isHookEnabled("auto-slash-command") ? safeHook("auto-slash-command", () => - createAutoSlashCommandHook({ skills: mergedSkills })) + createAutoSlashCommandHook({ + skills: mergedSkills, + pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + })) : null return { categorySkillReminder, autoSlashCommand } diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 492dd17db..3e909e785 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -12,6 +12,7 @@ import { createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, createHashlineReadEnhancerHook, + createReadImageResizerHook, createJsonErrorRecoveryHook, } from "../../hooks" import { @@ -33,6 +34,7 @@ export type ToolGuardHooks = { writeExistingFileGuard: ReturnType | null hashlineReadEnhancer: ReturnType | null jsonErrorRecovery: ReturnType | null + readImageResizer: ReturnType | null } export function createToolGuardHooks(args: { @@ -98,13 +100,17 @@ export function createToolGuardHooks(args: { : null const hashlineReadEnhancer = isHookEnabled("hashline-read-enhancer") - ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? true } })) + ? safeHook("hashline-read-enhancer", () => createHashlineReadEnhancerHook(ctx, { hashline_edit: { enabled: pluginConfig.hashline_edit ?? false } })) : null const jsonErrorRecovery = isHookEnabled("json-error-recovery") ? safeHook("json-error-recovery", () => createJsonErrorRecoveryHook(ctx)) : null + const readImageResizer = isHookEnabled("read-image-resizer") + ? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx)) + : null + return { commentChecker, toolOutputTruncator, @@ -116,5 +122,6 @@ export function createToolGuardHooks(args: { writeExistingFileGuard, hashlineReadEnhancer, jsonErrorRecovery, + readImageResizer, } } diff --git a/src/plugin/tool-execute-after.ts b/src/plugin/tool-execute-after.ts index fa6c8dade..58717c9bb 100644 --- a/src/plugin/tool-execute-after.ts +++ b/src/plugin/tool-execute-after.ts @@ -43,6 +43,7 @@ export function createToolExecuteAfterHandler(args: { await hooks.delegateTaskRetry?.["tool.execute.after"]?.(input, output) await hooks.atlasHook?.["tool.execute.after"]?.(input, output) await hooks.taskResumeInfo?.["tool.execute.after"]?.(input, output) + await hooks.readImageResizer?.["tool.execute.after"]?.(input, output) await hooks.hashlineReadEnhancer?.["tool.execute.after"]?.(input, output) await hooks.jsonErrorRecovery?.["tool.execute.after"]?.(input, output) } diff --git a/src/plugin/tool-execute-before.test.ts b/src/plugin/tool-execute-before.test.ts index b3cd3f7fe..7383bfb63 100644 --- a/src/plugin/tool-execute-before.test.ts +++ b/src/plugin/tool-execute-before.test.ts @@ -1,5 +1,6 @@ const { describe, expect, test } = require("bun:test") const { createToolExecuteBeforeHandler } = require("./tool-execute-before") +const { createToolRegistry } = require("./tool-registry") describe("createToolExecuteBeforeHandler", () => { test("does not execute subagent question blocker hook for question tool", async () => { @@ -219,4 +220,54 @@ describe("createToolExecuteBeforeHandler", () => { }) }) +describe("createToolRegistry", () => { + function createRegistryInput(overrides = {}) { + return { + ctx: { + directory: process.cwd(), + client: {}, + }, + pluginConfig: { + ...overrides, + }, + managers: { + backgroundManager: {}, + tmuxSessionManager: {}, + skillMcpManager: {}, + }, + skillContext: { + mergedSkills: [], + availableSkills: [], + browserProvider: "playwright", + disabledSkills: new Set(), + }, + availableCategories: [], + } + } + + describe("#given hashline_edit is undefined", () => { + describe("#when creating tool registry", () => { + test("#then should not register edit tool", () => { + const result = createToolRegistry(createRegistryInput()) + + expect(result.filteredTools.edit).toBeUndefined() + }) + }) + }) + + describe("#given hashline_edit is true", () => { + describe("#when creating tool registry", () => { + test("#then should register edit tool", () => { + const result = createToolRegistry( + createRegistryInput({ + hashline_edit: true, + }), + ) + + expect(result.filteredTools.edit).toBeDefined() + }) + }) + }) +}) + export {} diff --git a/src/plugin/tool-registry.ts b/src/plugin/tool-registry.ts index 21d7901f4..3b441c197 100644 --- a/src/plugin/tool-registry.ts +++ b/src/plugin/tool-registry.ts @@ -67,6 +67,7 @@ export function createToolRegistry(args: { disabledSkills: skillContext.disabledSkills, availableCategories, availableSkills: skillContext.availableSkills, + syncPollTimeoutMs: pluginConfig.background_task?.syncPollTimeoutMs, onSyncSessionCreated: async (event) => { log("[index] onSyncSessionCreated callback", { sessionID: event.sessionID, @@ -94,7 +95,10 @@ export function createToolRegistry(args: { getSessionID: getSessionIDForMcp, }) - const commands = discoverCommandsSync(ctx.directory) + const commands = discoverCommandsSync(ctx.directory, { + pluginsEnabled: pluginConfig.claude_code?.plugins ?? true, + enabledPluginsOverride: pluginConfig.claude_code?.plugins_override, + }) const skillTool = createSkillTool({ commands, skills: skillContext.mergedSkills, @@ -113,7 +117,7 @@ export function createToolRegistry(args: { } : {} - const hashlineEnabled = pluginConfig.hashline_edit ?? true + const hashlineEnabled = pluginConfig.hashline_edit ?? false const hashlineToolsRecord: Record = hashlineEnabled ? { edit: createHashlineEditTool() } : {} diff --git a/src/shared/AGENTS.md b/src/shared/AGENTS.md index 2cc20f9b6..18bd85fee 100644 --- a/src/shared/AGENTS.md +++ b/src/shared/AGENTS.md @@ -1,6 +1,6 @@ -# src/shared/ — 101 Utility Files in 13 Categories +# src/shared/ — 95+ Utility Files in 13 Categories -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW @@ -33,7 +33,7 @@ resolveModel(input) 4. System default: Ultimate fallback ``` -Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-name-matcher.ts` (fuzzy matching). +Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-availability.ts` (fuzzy matching). ## MIGRATION SYSTEM diff --git a/src/shared/fallback-model-availability.ts b/src/shared/fallback-model-availability.ts index 2162f422b..cae252177 100644 --- a/src/shared/fallback-model-availability.ts +++ b/src/shared/fallback-model-availability.ts @@ -1,6 +1,6 @@ import { readConnectedProvidersCache } from "./connected-providers-cache" import { log } from "./logger" -import { fuzzyMatchModel } from "./model-name-matcher" +import { fuzzyMatchModel } from "./model-availability" type FallbackEntry = { providers: string[]; model: string } diff --git a/src/shared/index.ts b/src/shared/index.ts index 09187602f..8615a7750 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -34,6 +34,7 @@ export * from "./system-directive" export * from "./agent-tool-restrictions" export * from "./model-requirements" export * from "./model-resolver" +export { normalizeModel, normalizeModelID } from "./model-normalization" export { normalizeFallbackModels } from "./model-resolver" export { resolveModelPipeline } from "./model-resolution-pipeline" export type { diff --git a/src/shared/model-format-normalizer.test.ts b/src/shared/model-format-normalizer.test.ts new file mode 100644 index 000000000..d28ab975b --- /dev/null +++ b/src/shared/model-format-normalizer.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "bun:test" +import { normalizeModelFormat } from "./model-format-normalizer" + +describe("normalizeModelFormat", () => { + describe("string format input", () => { + it("splits provider/model format correctly", () => { + const result = normalizeModelFormat("opencode/glm-5-free") + expect(result).toEqual({ providerID: "opencode", modelID: "glm-5-free" }) + }) + + it("handles provider with multiple slashes", () => { + const result = normalizeModelFormat("anthropic/claude-opus-4-6/max") + expect(result).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6/max" }) + }) + + it("returns undefined for malformed string without separator", () => { + const result = normalizeModelFormat("invalid") + expect(result).toBeUndefined() + }) + + it("returns undefined for empty string", () => { + const result = normalizeModelFormat("") + expect(result).toBeUndefined() + }) + }) + + describe("object format input", () => { + it("passthroughs object format unchanged", () => { + const input = { providerID: "opencode", modelID: "glm-5-free" } + const result = normalizeModelFormat(input) + expect(result).toEqual(input) + }) + }) + + describe("edge cases", () => { + it("returns undefined for null", () => { + const result = normalizeModelFormat(null) + expect(result).toBeUndefined() + }) + + it("returns undefined for undefined", () => { + const result = normalizeModelFormat(undefined) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/src/shared/model-format-normalizer.ts b/src/shared/model-format-normalizer.ts new file mode 100644 index 000000000..98d255f78 --- /dev/null +++ b/src/shared/model-format-normalizer.ts @@ -0,0 +1,20 @@ +export function normalizeModelFormat( + model: string | { providerID: string; modelID: string } +): { providerID: string; modelID: string } | undefined { + if (!model) { + return undefined + } + + if (typeof model === "object" && "providerID" in model && "modelID" in model) { + return { providerID: model.providerID, modelID: model.modelID } + } + + if (typeof model === "string") { + const parts = model.split("/") + if (parts.length >= 2) { + return { providerID: parts[0], modelID: parts.slice(1).join("/") } + } + } + + return undefined +} diff --git a/src/shared/model-name-matcher.ts b/src/shared/model-name-matcher.ts deleted file mode 100644 index bcdd3fb41..000000000 --- a/src/shared/model-name-matcher.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { log } from "./logger" - -function normalizeModelName(name: string): string { - return name - .toLowerCase() - .replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3") -} - -export function fuzzyMatchModel( - target: string, - available: Set, - providers?: string[], -): string | null { - log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers }) - - if (available.size === 0) { - log("[fuzzyMatchModel] empty available set") - return null - } - - const targetNormalized = normalizeModelName(target) - - let candidates = Array.from(available) - if (providers && providers.length > 0) { - const providerSet = new Set(providers) - candidates = candidates.filter((model) => { - const [provider] = model.split("/") - return providerSet.has(provider) - }) - log("[fuzzyMatchModel] filtered by providers", { - candidateCount: candidates.length, - candidates: candidates.slice(0, 10), - }) - } - - if (candidates.length === 0) { - log("[fuzzyMatchModel] no candidates after filter") - return null - } - - const matches = candidates.filter((model) => - normalizeModelName(model).includes(targetNormalized), - ) - - log("[fuzzyMatchModel] substring matches", { - targetNormalized, - matchCount: matches.length, - matches, - }) - - if (matches.length === 0) { - return null - } - - const exactMatch = matches.find( - (model) => normalizeModelName(model) === targetNormalized, - ) - if (exactMatch) { - log("[fuzzyMatchModel] exact match found", { exactMatch }) - return exactMatch - } - - const exactModelIdMatches = matches.filter((model) => { - const modelId = model.split("/").slice(1).join("/") - return normalizeModelName(modelId) === targetNormalized - }) - if (exactModelIdMatches.length > 0) { - const result = exactModelIdMatches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] exact model ID match found", { - result, - candidateCount: exactModelIdMatches.length, - }) - return result - } - - const result = matches.reduce((shortest, current) => - current.length < shortest.length ? current : shortest, - ) - log("[fuzzyMatchModel] shortest match", { result }) - return result -} diff --git a/src/shared/model-normalization.test.ts b/src/shared/model-normalization.test.ts new file mode 100644 index 000000000..2931690db --- /dev/null +++ b/src/shared/model-normalization.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { normalizeModel, normalizeModelID } from "./model-normalization" + +describe("normalizeModel", () => { + describe("#given undefined input", () => { + test("#when normalizeModel is called with undefined #then returns undefined", () => { + // given + const input = undefined + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given empty string", () => { + test("#when normalizeModel is called with empty string #then returns undefined", () => { + // given + const input = "" + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given whitespace-only string", () => { + test("#when normalizeModel is called with whitespace-only string #then returns undefined", () => { + // given + const input = " " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) + + describe("#given valid model string", () => { + test("#when normalizeModel is called with valid model string #then returns same string", () => { + // given + const input = "claude-3-opus" + + // when + const result = normalizeModel(input) + + // then + expect(result).toBe("claude-3-opus") + }) + }) + + describe("#given string with leading and trailing spaces", () => { + test("#when normalizeModel is called with spaces #then returns trimmed string", () => { + // given + const input = " claude-3-opus " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBe("claude-3-opus") + }) + }) + + describe("#given string with only spaces", () => { + test("#when normalizeModel is called with only spaces #then returns undefined", () => { + // given + const input = " " + + // when + const result = normalizeModel(input) + + // then + expect(result).toBeUndefined() + }) + }) +}) + +describe("normalizeModelID", () => { + describe("#given model with dots in version numbers", () => { + test("#when normalizeModelID is called with claude-3.5-sonnet #then returns claude-3-5-sonnet", () => { + // given + const input = "claude-3.5-sonnet" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("claude-3-5-sonnet") + }) + }) + + describe("#given model without dots", () => { + test("#when normalizeModelID is called with claude-opus #then returns unchanged", () => { + // given + const input = "claude-opus" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("claude-opus") + }) + }) + + describe("#given model with multiple dot-numbers", () => { + test("#when normalizeModelID is called with model.1.2 #then returns model-1-2", () => { + // given + const input = "model.1.2" + + // when + const result = normalizeModelID(input) + + // then + expect(result).toBe("model-1-2") + }) + }) +}) diff --git a/src/shared/model-normalization.ts b/src/shared/model-normalization.ts new file mode 100644 index 000000000..999ffb401 --- /dev/null +++ b/src/shared/model-normalization.ts @@ -0,0 +1,8 @@ +export function normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim() + return trimmed || undefined +} + +export function normalizeModelID(modelID: string): string { + return modelID.replace(/\.(\d+)/g, "-$1") +} diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts index 0d90b4f16..8eb12e5c5 100644 --- a/src/shared/model-resolution-pipeline.ts +++ b/src/shared/model-resolution-pipeline.ts @@ -3,6 +3,7 @@ import * as connectedProvidersCache from "./connected-providers-cache" import { fuzzyMatchModel } from "./model-availability" import type { FallbackEntry } from "./model-requirements" import { transformModelForProvider } from "./provider-model-id-transform" +import { normalizeModel } from "./model-normalization" export type ModelResolutionRequest = { intent?: { @@ -33,12 +34,9 @@ export type ModelResolutionResult = { variant?: string attempted?: string[] reason?: string + explicitUserConfig?: boolean } -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModelPipeline( request: ModelResolutionRequest, @@ -58,7 +56,7 @@ export function resolveModelPipeline( const normalizedUserModel = normalizeModel(intent?.userModel) if (normalizedUserModel) { log("Model resolved via config override", { model: normalizedUserModel }) - return { model: normalizedUserModel, provenance: "override" } + return { model: normalizedUserModel, provenance: "override", explicitUserConfig: true } } const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel) diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index e2e02fce3..977112cb1 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -1,4 +1,5 @@ import type { FallbackEntry } from "./model-requirements" +import { normalizeModel } from "./model-normalization" import { resolveModelPipeline } from "./model-resolution-pipeline" export type ModelResolutionInput = { @@ -29,10 +30,6 @@ export type ExtendedModelResolutionInput = { systemDefaultModel?: string } -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModel(input: ModelResolutionInput): string | undefined { return ( diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md index 2604ad9ec..48c992383 100644 --- a/src/tools/AGENTS.md +++ b/src/tools/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/ — 26 Tools Across 15 Directories -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW @@ -91,10 +91,10 @@ | Category | Model | Domain | |----------|-------|--------| -| visual-engineering | gemini-3-pro | Frontend, UI/UX | +| visual-engineering | gemini-3.1-pro high | Frontend, UI/UX | | ultrabrain | gpt-5.3-codex xhigh | Hard logic | | deep | gpt-5.3-codex medium | Autonomous problem-solving | -| artistry | gemini-3-pro high | Creative approaches | +| artistry | gemini-3.1-pro high | Creative approaches | | quick | claude-haiku-4-5 | Trivial tasks | | unspecified-low | claude-sonnet-4-6 | Moderate effort | | unspecified-high | claude-opus-4-6 max | High effort | diff --git a/src/tools/background-task/AGENTS.md b/src/tools/background-task/AGENTS.md index bdf486fe3..32285831b 100644 --- a/src/tools/background-task/AGENTS.md +++ b/src/tools/background-task/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/background-task/ — Background Task Tool Wrappers -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/call-omo-agent/AGENTS.md b/src/tools/call-omo-agent/AGENTS.md index adbe35fe4..1b551f304 100644 --- a/src/tools/call-omo-agent/AGENTS.md +++ b/src/tools/call-omo-agent/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/call-omo-agent/ — Direct Agent Invocation Tool -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/delegate-task/AGENTS.md b/src/tools/delegate-task/AGENTS.md index 8f5334260..8adeedfa5 100644 --- a/src/tools/delegate-task/AGENTS.md +++ b/src/tools/delegate-task/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/delegate-task/ — Task Delegation Engine -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/delegate-task/executor-types.ts b/src/tools/delegate-task/executor-types.ts index 136f6dbf2..ad8c01879 100644 --- a/src/tools/delegate-task/executor-types.ts +++ b/src/tools/delegate-task/executor-types.ts @@ -12,6 +12,7 @@ export interface ExecutorContext { browserProvider?: BrowserAutomationProvider agentOverrides?: AgentOverrides onSyncSessionCreated?: (event: { sessionID: string; parentID: string; title: string }) => Promise + syncPollTimeoutMs?: number } export interface ParentContext { diff --git a/src/tools/delegate-task/model-selection.ts b/src/tools/delegate-task/model-selection.ts index 188e3e374..e361ed642 100644 --- a/src/tools/delegate-task/model-selection.ts +++ b/src/tools/delegate-task/model-selection.ts @@ -1,11 +1,8 @@ import type { FallbackEntry } from "../../shared/model-requirements" +import { normalizeModel } from "../../shared/model-normalization" import { fuzzyMatchModel } from "../../shared/model-availability" import { transformModelForProvider } from "../../shared/provider-model-id-transform" -function normalizeModel(model?: string): string | undefined { - const trimmed = model?.trim() - return trimmed || undefined -} export function resolveModelForDelegateTask(input: { userModel?: string diff --git a/src/tools/delegate-task/subagent-resolver.test.ts b/src/tools/delegate-task/subagent-resolver.test.ts index 8482c6cf6..6c6e78a3f 100644 --- a/src/tools/delegate-task/subagent-resolver.test.ts +++ b/src/tools/delegate-task/subagent-resolver.test.ts @@ -79,4 +79,56 @@ describe("resolveSubagentExecution", () => { error: "network timeout", }) }) + + test("uses inherited model for custom agents without explicit model", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + "openai/gpt-5.3-codex", + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "openai", + modelID: "gpt-5.3-codex", + }) + }) + + test("uses system default model when inherited model is unavailable", async () => { + //#given + const args = createBaseArgs({ subagent_type: "translator" }) + const executorCtx = createExecutorContext(async () => ({ + data: [{ name: "translator", mode: "subagent" }], + })) + + //#when + const result = await resolveSubagentExecution( + args, + executorCtx, + "sisyphus", + "deep", + undefined, + "anthropic/claude-opus-4-6", + ) + + //#then + expect(result.error).toBeUndefined() + expect(result.agentToUse).toBe("translator") + expect(result.categoryModel).toEqual({ + providerID: "anthropic", + modelID: "claude-opus-4-6", + }) + }) }) diff --git a/src/tools/delegate-task/subagent-resolver.ts b/src/tools/delegate-task/subagent-resolver.ts index 043243db2..5ef52b576 100644 --- a/src/tools/delegate-task/subagent-resolver.ts +++ b/src/tools/delegate-task/subagent-resolver.ts @@ -2,7 +2,7 @@ import type { DelegateTaskArgs } from "./types" import type { ExecutorContext } from "./executor-types" import { isPlanFamily } from "./constants" import { SISYPHUS_JUNIOR_AGENT } from "./sisyphus-junior-agent" -import { parseModelString } from "./model-string-parser" +import { normalizeModelFormat } from "../../shared/model-format-normalizer" import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements" import { getAgentDisplayName, getAgentConfigKey } from "../../shared/agent-display-names" import { normalizeSDKResponse } from "../../shared" @@ -15,7 +15,9 @@ export async function resolveSubagentExecution( args: DelegateTaskArgs, executorCtx: ExecutorContext, parentAgent: string | undefined, - categoryExamples: string + categoryExamples: string, + inheritedModel?: string, + systemDefaultModel?: string, ): Promise<{ agentToUse: string; categoryModel: { providerID: string; modelID: string; variant?: string } | undefined; fallbackChain?: FallbackEntry[]; error?: string }> { const { client, agentOverrides } = executorCtx @@ -99,8 +101,9 @@ Create the work plan directly - that's your job as the planning agent.`, if (agentOverride?.model || agentRequirement || matchedAgent.model) { const availableModels = await getAvailableModelsForDelegateTask(client) - const matchedAgentModelStr = matchedAgent.model - ? `${matchedAgent.model.providerID}/${matchedAgent.model.modelID}` + const normalizedMatchedModel = normalizeModelFormat(matchedAgent.model as Parameters[0]) + const matchedAgentModelStr = normalizedMatchedModel + ? `${normalizedMatchedModel.providerID}/${normalizedMatchedModel.modelID}` : undefined const resolution = resolveModelForDelegateTask({ @@ -112,10 +115,10 @@ Create the work plan directly - that's your job as the planning agent.`, }) if (resolution) { - const parsed = parseModelString(resolution.model) - if (parsed) { + const normalized = normalizeModelFormat(resolution.model) + if (normalized) { const variantToUse = agentOverride?.variant ?? resolution.variant - categoryModel = variantToUse ? { ...parsed, variant: variantToUse } : parsed + categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized } } } @@ -123,6 +126,16 @@ Create the work plan directly - that's your job as the planning agent.`, if (!categoryModel && matchedAgent.model) { categoryModel = matchedAgent.model } + + if (!categoryModel) { + const fallbackModel = inheritedModel ?? systemDefaultModel + if (fallbackModel) { + const parsedFallback = parseModelString(fallbackModel) + if (parsedFallback) { + categoryModel = parsedFallback + } + } + } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) log("[delegate-task] Failed to resolve subagent execution", { diff --git a/src/tools/delegate-task/sync-continuation.ts b/src/tools/delegate-task/sync-continuation.ts index b31e19508..a65b20613 100644 --- a/src/tools/delegate-task/sync-continuation.ts +++ b/src/tools/delegate-task/sync-continuation.ts @@ -18,7 +18,7 @@ export async function executeSyncContinuation( executorCtx: ExecutorContext, deps: SyncContinuationDeps = syncContinuationDeps ): Promise { - const { client } = executorCtx + const { client, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() const taskId = `resume_sync_${args.session_id!.slice(0, 8)}` const startTime = new Date() @@ -112,7 +112,7 @@ export async function executeSyncContinuation( toastManager, taskId, anchorMessageCount, - }) + }, syncPollTimeoutMs) if (pollError) { return pollError } diff --git a/src/tools/delegate-task/sync-poll-timeout.test.ts b/src/tools/delegate-task/sync-poll-timeout.test.ts new file mode 100644 index 000000000..b89b7887a --- /dev/null +++ b/src/tools/delegate-task/sync-poll-timeout.test.ts @@ -0,0 +1,176 @@ +declare const require: (name: string) => any +const { describe, test, expect, beforeEach, afterEach } = require("bun:test") +import { __setTimingConfig, __resetTimingConfig, DEFAULT_SYNC_POLL_TIMEOUT_MS } from "./timing" + +function createMockCtx(aborted = false) { + const controller = new AbortController() + if (aborted) controller.abort() + return { + sessionID: "parent-session", + messageID: "parent-message", + agent: "test-agent", + abort: controller.signal, + } +} + +function createNeverCompleteClient(sessionID: string) { + return { + session: { + messages: async () => ({ + data: [{ info: { id: "msg_001", role: "user", time: { created: 1000 } } }], + }), + status: async () => ({ data: { [sessionID]: { type: "idle" } } }), + }, + } +} + +async function withMockedDateNow(stepMs: number, run: () => Promise) { + const originalDateNow = Date.now + let now = 0 + + Date.now = () => { + const current = now + now += stepMs + return current + } + + try { + await run() + } finally { + Date.now = originalDateNow + } +} + +describe("syncPollTimeoutMs threading", () => { + beforeEach(() => { + __setTimingConfig({ + POLL_INTERVAL_MS: 10, + MIN_STABILITY_TIME_MS: 0, + STABILITY_POLLS_REQUIRED: 1, + MAX_POLL_TIME_MS: 5000, + }) + }) + + afterEach(() => { + __resetTimingConfig() + }) + + describe("#given pollSyncSession timeoutMs input", () => { + describe("#when custom timeout is provided", () => { + test("#then custom timeout value is used", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_custom") + + await withMockedDateNow(60_000, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_custom", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }, 120_000) + + expect(result).toBe("Poll timeout reached after 120000ms for session ses_custom") + }) + }) + }) + + describe("#when timeoutMs is omitted", () => { + test("#then default timeout constant is used", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_default") + + expect(DEFAULT_SYNC_POLL_TIMEOUT_MS).toBe(600_000) + + await withMockedDateNow(300_000, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_default", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }) + + expect(result).toBe(`Poll timeout reached after ${DEFAULT_SYNC_POLL_TIMEOUT_MS}ms for session ses_default`) + }) + }) + }) + + describe("#when timeoutMs is lower than minimum guard", () => { + test("#then minimum 50ms timeout is enforced", async () => { + const { pollSyncSession } = require("./sync-session-poller") + const mockClient = createNeverCompleteClient("ses_guard") + + await withMockedDateNow(25, async () => { + const result = await pollSyncSession(createMockCtx(), mockClient, { + sessionID: "ses_guard", + agentToUse: "test-agent", + toastManager: null, + taskId: undefined, + }, 10) + + expect(result).toBe("Poll timeout reached after 50ms for session ses_guard") + }) + }) + }) + }) + + describe("#given unstable-agent-task path", () => { + describe("#when syncPollTimeoutMs is set in executor context", () => { + test("#then unstable path uses configured timeout budget", async () => { + const { executeUnstableAgentTask } = require("./unstable-agent-task") + + let statusCallCount = 0 + const mockClient = { + session: { + status: async () => { + statusCallCount++ + return { data: { ses_unstable: { type: "idle" } } } + }, + messages: async () => ({ + data: [ + { + info: { id: "msg_001", role: "assistant", time: { created: 2000 } }, + parts: [{ type: "text", text: "unstable path done" }], + }, + ], + }), + }, + } + + const mockManager = { + launch: async () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }), + getTask: () => ({ id: "task_001", sessionID: "ses_unstable", status: "running" }), + } + + const result = await executeUnstableAgentTask( + { + description: "unstable timeout threading", + prompt: "run", + category: "unspecified-low", + run_in_background: false, + load_skills: [], + command: undefined, + }, + createMockCtx(), + { + manager: mockManager, + client: mockClient, + syncPollTimeoutMs: 0, + }, + { + sessionID: "parent-session", + messageID: "parent-message", + model: "gpt-test", + agent: "test-agent", + }, + "test-agent", + undefined, + undefined, + "gpt-test" + ) + + expect(statusCallCount).toBe(0) + expect(result).toContain("SUPERVISED TASK COMPLETED SUCCESSFULLY") + }) + }) + }) +}) diff --git a/src/tools/delegate-task/sync-session-poller.test.ts b/src/tools/delegate-task/sync-session-poller.test.ts index 61defaf87..279116a17 100644 --- a/src/tools/delegate-task/sync-session-poller.test.ts +++ b/src/tools/delegate-task/sync-session-poller.test.ts @@ -273,7 +273,7 @@ describe("pollSyncSession", () => { agentToUse: "test-agent", toastManager: null, taskId: undefined, - }) + }, 0) //#then - timeout returns error string expect(result).toBe("Poll timeout reached after 50ms for session ses_timeout") diff --git a/src/tools/delegate-task/sync-session-poller.ts b/src/tools/delegate-task/sync-session-poller.ts index 9c8cb2567..3d5e88df2 100644 --- a/src/tools/delegate-task/sync-session-poller.ts +++ b/src/tools/delegate-task/sync-session-poller.ts @@ -1,6 +1,6 @@ import type { ToolContextWithMetadata, OpencodeClient } from "./types" import type { SessionMessage } from "./executor-types" -import { getTimingConfig } from "./timing" +import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing" import { log } from "../../shared/logger" import { normalizeSDKResponse } from "../../shared" @@ -32,10 +32,11 @@ export async function pollSyncSession( toastManager: { removeTask: (id: string) => void } | null | undefined taskId: string | undefined anchorMessageCount?: number - } + }, + timeoutMs?: number ): Promise { const syncTiming = getTimingConfig() - const maxPollTimeMs = Math.max(syncTiming.MAX_POLL_TIME_MS, 50) + const maxPollTimeMs = Math.max(timeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS, 50) const pollStart = Date.now() let pollCount = 0 let timedOut = false diff --git a/src/tools/delegate-task/sync-task.ts b/src/tools/delegate-task/sync-task.ts index f384b370f..2ff600d09 100644 --- a/src/tools/delegate-task/sync-task.ts +++ b/src/tools/delegate-task/sync-task.ts @@ -23,7 +23,7 @@ export async function executeSyncTask( fallbackChain?: import("../../shared/model-requirements").FallbackEntry[], deps: SyncTaskDeps = syncTaskDeps ): Promise { - const { client, directory, onSyncSessionCreated } = executorCtx + const { client, directory, onSyncSessionCreated, syncPollTimeoutMs } = executorCtx const toastManager = getTaskToastManager() let taskId: string | undefined let syncSessionID: string | undefined @@ -117,7 +117,7 @@ export async function executeSyncTask( agentToUse, toastManager, taskId, - }) + }, syncPollTimeoutMs) if (pollError) { return pollError } diff --git a/src/tools/delegate-task/timing.ts b/src/tools/delegate-task/timing.ts index 5510d4e2d..5b404f3b8 100644 --- a/src/tools/delegate-task/timing.ts +++ b/src/tools/delegate-task/timing.ts @@ -6,6 +6,8 @@ let WAIT_FOR_SESSION_TIMEOUT_MS = 30000 let MAX_POLL_TIME_MS = 10 * 60 * 1000 let SESSION_CONTINUATION_STABILITY_MS = 5000 +export const DEFAULT_SYNC_POLL_TIMEOUT_MS = 600_000 + export function getTimingConfig() { return { POLL_INTERVAL_MS, diff --git a/src/tools/delegate-task/tools.test.ts b/src/tools/delegate-task/tools.test.ts index 8c0b01acf..bb0b5ec29 100644 --- a/src/tools/delegate-task/tools.test.ts +++ b/src/tools/delegate-task/tools.test.ts @@ -1357,29 +1357,58 @@ describe("sisyphus-task", () => { return { data: {} } }) + const baseTime = Date.now() + const initialMessages = [ + { + info: { + id: "msg_001", + role: "user", + agent: "sisyphus-junior", + model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, + variant: "max", + time: { created: baseTime }, + }, + parts: [{ type: "text", text: "previous message" }], + }, + { + info: { id: "msg_002", role: "assistant", time: { created: baseTime + 1 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Completed." }], + }, + ] + + const messagesCallCounts: Record = {} + const mockClient = { session: { prompt: promptMock, promptAsync: promptMock, - messages: async () => ({ - data: [ - { - info: { - id: "msg_001", - role: "user", - agent: "sisyphus-junior", - model: { providerID: "anthropic", modelID: "claude-opus-4-6" }, - variant: "max", - time: { created: Date.now() }, + messages: async (input: any) => { + const sessionID = input?.path?.id + if (typeof sessionID !== "string") { + return { data: [] } + } + + const callCount = (messagesCallCounts[sessionID] ?? 0) + 1 + messagesCallCounts[sessionID] = callCount + + if (sessionID !== "ses_var_test") { + return { data: [] } + } + + if (callCount === 1) { + return { data: initialMessages } + } + + return { + data: [ + ...initialMessages, + { + info: { id: "msg_003", role: "assistant", time: { created: baseTime + 2 }, finish: "end_turn" }, + parts: [{ type: "text", text: "Continued." }], }, - parts: [{ type: "text", text: "previous message" }], - }, - { - info: { id: "msg_002", role: "assistant", time: { created: Date.now() + 1 }, finish: "end_turn" }, - parts: [{ type: "text", text: "Completed." }], - }, - ], - }), + ], + } + }, status: async () => ({ data: { "ses_var_test": { type: "idle" } } }), }, config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) }, diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index fcd691f1f..9b0915330 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -226,7 +226,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini return executeUnstableAgentTask(args, ctx, options, parentContext, agentToUse, categoryModel, systemContent, actualModel) } } else { - const resolution = await resolveSubagentExecution(args, options, parentContext.agent, categoryExamples) + const resolution = await resolveSubagentExecution( + args, + options, + parentContext.agent, + categoryExamples, + inheritedModel, + systemDefaultModel, + ) if (resolution.error) { return resolution.error } diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 7c749d208..c51a1bde1 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -68,6 +68,7 @@ export interface DelegateTaskToolOptions { availableSkills?: AvailableSkill[] agentOverrides?: AgentOverrides onSyncSessionCreated?: (event: SyncSessionCreatedEvent) => Promise + syncPollTimeoutMs?: number } export interface BuildSystemContentInput { diff --git a/src/tools/delegate-task/unstable-agent-task.ts b/src/tools/delegate-task/unstable-agent-task.ts index c0972e7bd..ca6c38ee1 100644 --- a/src/tools/delegate-task/unstable-agent-task.ts +++ b/src/tools/delegate-task/unstable-agent-task.ts @@ -1,6 +1,6 @@ import type { DelegateTaskArgs, ToolContextWithMetadata } from "./types" import type { ExecutorContext, ParentContext, SessionMessage } from "./executor-types" -import { getTimingConfig } from "./timing" +import { DEFAULT_SYNC_POLL_TIMEOUT_MS, getTimingConfig } from "./timing" import { storeToolMetadata } from "../../features/tool-metadata-store" import { formatDuration } from "./time-formatter" import { formatDetailedError } from "./error-formatting" @@ -17,7 +17,7 @@ export async function executeUnstableAgentTask( systemContent: string | undefined, actualModel: string | undefined ): Promise { - const { manager, client } = executorCtx + const { manager, client, syncPollTimeoutMs } = executorCtx try { const task = await manager.launch({ @@ -80,7 +80,7 @@ export async function executeUnstableAgentTask( let stablePolls = 0 let terminalStatus: { status: string; error?: string } | undefined - while (Date.now() - pollStart < timingCfg.MAX_POLL_TIME_MS) { + while (Date.now() - pollStart < (syncPollTimeoutMs ?? DEFAULT_SYNC_POLL_TIMEOUT_MS)) { if (ctx.abort?.aborted) { return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}` } diff --git a/src/tools/glob/cli.ts b/src/tools/glob/cli.ts index b621383a6..996133383 100644 --- a/src/tools/glob/cli.ts +++ b/src/tools/glob/cli.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import { spawn } from "bun" import { resolveGrepCli, @@ -119,10 +120,9 @@ async function runRgFilesInternal( if (isRg) { const args = buildRgArgs(options) - const paths = options.paths?.length ? options.paths : ["."] - args.push(...paths) + cwd = options.paths?.[0] || "." + args.push(".") command = [cli.path, ...args] - cwd = undefined } else if (isWindows) { command = buildPowerShellCommand(options) cwd = undefined @@ -177,7 +177,7 @@ async function runRgFilesInternal( let filePath: string if (isRg) { - filePath = line + filePath = cwd ? resolve(cwd, line) : line } else if (isWindows) { filePath = line.trim() } else { diff --git a/src/tools/glob/tools.ts b/src/tools/glob/tools.ts index 361d43cde..cdfa983b4 100644 --- a/src/tools/glob/tools.ts +++ b/src/tools/glob/tools.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRgFiles } from "./cli" @@ -22,16 +23,17 @@ export function createGlobTools(ctx: PluginInput): Record { + execute: async (args, context) => { try { const cli = await resolveGrepCliWithAutoInstall() - const searchPath = args.path ?? ctx.directory - const paths = [searchPath] + const runtimeCtx = context as Record + const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory + const searchPath = args.path ? resolve(dir, args.path) : dir const result = await runRgFiles( { pattern: args.pattern, - paths, + paths: [searchPath], }, cli ) diff --git a/src/tools/grep/tools.ts b/src/tools/grep/tools.ts index e4d0e0e42..b00c47540 100644 --- a/src/tools/grep/tools.ts +++ b/src/tools/grep/tools.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" import { runRg, runRgCount } from "./cli" @@ -32,10 +33,12 @@ export function createGrepTools(ctx: PluginInput): Record { + execute: async (args, context) => { try { const globs = args.include ? [args.include] : undefined - const searchPath = args.path ?? ctx.directory + const runtimeCtx = context as Record + const dir = typeof runtimeCtx.directory === "string" ? runtimeCtx.directory : ctx.directory + const searchPath = args.path ? resolve(dir, args.path) : dir const paths = [searchPath] const outputMode = args.output_mode ?? "files_with_matches" const headLimit = args.head_limit ?? 0 diff --git a/src/tools/hashline-edit/AGENTS.md b/src/tools/hashline-edit/AGENTS.md index 3054b21d2..0eb4a8233 100644 --- a/src/tools/hashline-edit/AGENTS.md +++ b/src/tools/hashline-edit/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/hashline-edit/ — Hash-Anchored File Edit Tool -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/hashline-edit/tool-description.ts b/src/tools/hashline-edit/tool-description.ts index 2d452ccfa..c8a566860 100644 --- a/src/tools/hashline-edit/tool-description.ts +++ b/src/tools/hashline-edit/tool-description.ts @@ -34,7 +34,7 @@ FILE CREATION: CRITICAL: only unanchored append/prepend can create a missing file. OPERATION CHOICE: - replace with pos only -> replace one line at pos (MOST COMMON for single-line edits) + replace with pos only -> replace one line at pos replace with pos+end -> replace ENTIRE range pos..end as a block (ranges MUST NOT overlap across edits) append with pos/end anchor -> insert after that anchor prepend with pos/end anchor -> insert before that anchor diff --git a/src/tools/look-at/image-converter.test.ts b/src/tools/look-at/image-converter.test.ts new file mode 100644 index 000000000..37c9cdf2a --- /dev/null +++ b/src/tools/look-at/image-converter.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" +import { existsSync, mkdtempSync, writeFileSync, unlinkSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const originalChildProcess = await import("node:child_process") + +const execFileSyncMock = mock((_command: string, _args: string[]) => "") +const execSyncMock = mock(() => { + throw new Error("execSync should not be called") +}) + +mock.module("node:child_process", () => ({ + ...originalChildProcess, + execFileSync: execFileSyncMock, + execSync: execSyncMock, +})) + +const { convertImageToJpeg } = await import("./image-converter") + +describe("image-converter command execution safety", () => { + beforeEach(() => { + execFileSyncMock.mockReset() + execSyncMock.mockReset() + }) + + test("uses execFileSync with argument arrays for conversion commands", () => { + const testDir = mkdtempSync(join(tmpdir(), "img-converter-test-")) + const inputPath = join(testDir, "evil$(touch_pwn).heic") + writeFileSync(inputPath, "fake-heic-data") + + execFileSyncMock.mockImplementation((command: string, args: string[]) => { + if (command === "sips") { + const outIndex = args.indexOf("--out") + const outputPath = outIndex >= 0 ? args[outIndex + 1] : undefined + if (outputPath) writeFileSync(outputPath, "jpeg") + } else if (command === "convert") { + writeFileSync(args[1], "jpeg") + } + return "" + }) + + const outputPath = convertImageToJpeg(inputPath, "image/heic") + + expect(execSyncMock).not.toHaveBeenCalled() + expect(execFileSyncMock).toHaveBeenCalled() + + const [firstCommand, firstArgs] = execFileSyncMock.mock.calls[0] as [string, string[]] + expect(typeof firstCommand).toBe("string") + expect(Array.isArray(firstArgs)).toBe(true) + expect(firstArgs).toContain(inputPath) + expect(firstArgs.join(" ")).not.toContain(`\"${inputPath}\"`) + + expect(existsSync(outputPath)).toBe(true) + + if (existsSync(outputPath)) unlinkSync(outputPath) + if (existsSync(inputPath)) unlinkSync(inputPath) + rmSync(testDir, { recursive: true, force: true }) + }) +}) diff --git a/src/tools/look-at/image-converter.ts b/src/tools/look-at/image-converter.ts new file mode 100644 index 000000000..e95237ba3 --- /dev/null +++ b/src/tools/look-at/image-converter.ts @@ -0,0 +1,149 @@ +import { execFileSync } from "node:child_process" +import { existsSync, mkdtempSync, unlinkSync, writeFileSync, readFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { log } from "../../shared" + +const SUPPORTED_FORMATS = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/bmp", + "image/tiff", +]) + +const UNSUPPORTED_FORMATS = new Set([ + "image/heic", + "image/heif", + "image/x-canon-cr2", + "image/x-canon-crw", + "image/x-nikon-nef", + "image/x-nikon-nrw", + "image/x-sony-arw", + "image/x-sony-sr2", + "image/x-sony-srf", + "image/x-pentax-pef", + "image/x-olympus-orf", + "image/x-panasonic-raw", + "image/x-fuji-raf", + "image/x-adobe-dng", + "image/vnd.adobe.photoshop", + "image/x-photoshop", +]) + +export function needsConversion(mimeType: string): boolean { + if (SUPPORTED_FORMATS.has(mimeType)) { + return false + } + + if (UNSUPPORTED_FORMATS.has(mimeType)) { + return true + } + + return mimeType.startsWith("image/") +} + +export function convertImageToJpeg(inputPath: string, mimeType: string): string { + if (!existsSync(inputPath)) { + throw new Error(`File not found: ${inputPath}`) + } + + const tempDir = mkdtempSync(join(tmpdir(), "opencode-img-")) + const outputPath = join(tempDir, "converted.jpg") + + log(`[image-converter] Converting ${mimeType} to JPEG: ${inputPath}`) + + try { + if (process.platform === "darwin") { + try { + execFileSync("sips", ["-s", "format", "jpeg", inputPath, "--out", outputPath], { + stdio: "pipe", + encoding: "utf-8", + }) + + if (existsSync(outputPath)) { + log(`[image-converter] Converted using sips: ${outputPath}`) + return outputPath + } + } catch (sipsError) { + log(`[image-converter] sips failed: ${sipsError}`) + } + } + + try { + execFileSync("convert", [inputPath, outputPath], { + stdio: "pipe", + encoding: "utf-8", + }) + + if (existsSync(outputPath)) { + log(`[image-converter] Converted using ImageMagick: ${outputPath}`) + return outputPath + } + } catch (convertError) { + log(`[image-converter] ImageMagick convert failed: ${convertError}`) + } + + throw new Error( + `No image conversion tool available. Please install ImageMagick:\n` + + ` macOS: brew install imagemagick\n` + + ` Ubuntu/Debian: sudo apt install imagemagick\n` + + ` RHEL/CentOS: sudo yum install ImageMagick` + ) + } catch (error) { + try { + if (existsSync(outputPath)) { + unlinkSync(outputPath) + } + } catch {} + + throw error + } +} + +export function cleanupConvertedImage(filePath: string): void { + try { + if (existsSync(filePath)) { + unlinkSync(filePath) + log(`[image-converter] Cleaned up temporary file: ${filePath}`) + } + } catch (error) { + log(`[image-converter] Failed to cleanup ${filePath}: ${error}`) + } +} + +export function convertBase64ImageToJpeg( + base64Data: string, + mimeType: string +): { base64: string; tempFiles: string[] } { + const tempDir = mkdtempSync(join(tmpdir(), "opencode-b64-")) + const inputExt = mimeType.split("/")[1] || "bin" + const inputPath = join(tempDir, `input.${inputExt}`) + const tempFiles: string[] = [inputPath] + + try { + const cleanBase64 = base64Data.replace(/^data:[^;]+;base64,/, "") + const buffer = Buffer.from(cleanBase64, "base64") + writeFileSync(inputPath, buffer) + + log(`[image-converter] Converting Base64 ${mimeType} to JPEG`) + + const outputPath = convertImageToJpeg(inputPath, mimeType) + tempFiles.push(outputPath) + + const convertedBuffer = readFileSync(outputPath) + const convertedBase64 = convertedBuffer.toString("base64") + + log(`[image-converter] Base64 conversion successful`) + + return { base64: convertedBase64, tempFiles } + } catch (error) { + tempFiles.forEach(file => { + try { + if (existsSync(file)) unlinkSync(file) + } catch {} + }) + throw error + } +} diff --git a/src/tools/look-at/mime-type-inference.test.ts b/src/tools/look-at/mime-type-inference.test.ts new file mode 100644 index 000000000..69ac6e6bf --- /dev/null +++ b/src/tools/look-at/mime-type-inference.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { extractBase64Data, inferMimeTypeFromBase64, inferMimeTypeFromFilePath } from "./mime-type-inference" + +describe("mime type inference", () => { + test("returns MIME from data URL prefix", () => { + const mime = inferMimeTypeFromBase64("data:image/heic;base64,AAAAGGZ0eXBoZWlj") + expect(mime).toBe("image/heic") + }) + + test("detects HEIC from raw base64 magic bytes", () => { + const heicHeader = Buffer.from("00000018667479706865696300000000", "hex").toString("base64") + const mime = inferMimeTypeFromBase64(heicHeader) + expect(mime).toBe("image/heic") + }) + + test("detects HEIF from raw base64 magic bytes", () => { + const heifHeader = Buffer.from("00000018667479706865696600000000", "hex").toString("base64") + const mime = inferMimeTypeFromBase64(heifHeader) + expect(mime).toBe("image/heif") + }) + + test("falls back to png when base64 signature is unknown", () => { + const mime = inferMimeTypeFromBase64("dW5rbm93biBiaW5hcnk=") + expect(mime).toBe("image/png") + }) + + test("infers heic from file extension", () => { + const mime = inferMimeTypeFromFilePath("/tmp/photo.HEIC") + expect(mime).toBe("image/heic") + }) + + test("extracts raw base64 data from data URL", () => { + const base64 = extractBase64Data("data:image/png;base64,abc123") + expect(base64).toBe("abc123") + }) + + test("extracts raw base64 data from data URL with extra parameters", () => { + const base64 = extractBase64Data("data:image/heic;name=clip.heic;base64,abc123") + expect(base64).toBe("abc123") + }) +}) diff --git a/src/tools/look-at/mime-type-inference.ts b/src/tools/look-at/mime-type-inference.ts index 18954c46c..0718259de 100644 --- a/src/tools/look-at/mime-type-inference.ts +++ b/src/tools/look-at/mime-type-inference.ts @@ -8,12 +8,18 @@ export function inferMimeTypeFromBase64(base64Data: string): string { try { const cleanData = base64Data.replace(/^data:[^;]+;base64,/, "") - const header = atob(cleanData.slice(0, 16)) + const header = Buffer.from(cleanData.slice(0, 256), "base64").toString("binary") if (header.startsWith("\x89PNG")) return "image/png" if (header.startsWith("\xFF\xD8\xFF")) return "image/jpeg" if (header.startsWith("GIF8")) return "image/gif" if (header.startsWith("RIFF") && header.includes("WEBP")) return "image/webp" + if (header.includes("ftypheic") || header.includes("ftypheix") || header.includes("ftyphevc") || header.includes("ftyphevx")) { + return "image/heic" + } + if (header.includes("ftypheif") || header.includes("ftypmif1") || header.includes("ftypmsf1")) { + return "image/heif" + } if (header.startsWith("%PDF")) return "application/pdf" } catch { // invalid base64 - fall through @@ -29,8 +35,25 @@ export function inferMimeTypeFromFilePath(filePath: string): string { ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".tiff": "image/tiff", + ".tif": "image/tiff", ".heic": "image/heic", ".heif": "image/heif", + ".cr2": "image/x-canon-cr2", + ".crw": "image/x-canon-crw", + ".nef": "image/x-nikon-nef", + ".nrw": "image/x-nikon-nrw", + ".arw": "image/x-sony-arw", + ".sr2": "image/x-sony-sr2", + ".srf": "image/x-sony-srf", + ".pef": "image/x-pentax-pef", + ".orf": "image/x-olympus-orf", + ".raw": "image/x-panasonic-raw", + ".raf": "image/x-fuji-raf", + ".dng": "image/x-adobe-dng", + ".psd": "image/vnd.adobe.photoshop", ".mp4": "video/mp4", ".mpeg": "video/mpeg", ".mpg": "video/mpeg", diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 0d5c1c0b7..a4c67f051 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -13,6 +13,12 @@ import { inferMimeTypeFromFilePath, } from "./mime-type-inference" import { resolveMultimodalLookerAgentMetadata } from "./multimodal-agent-metadata" +import { + needsConversion, + convertImageToJpeg, + convertBase64ImageToJpeg, + cleanupConvertedImage, +} from "./image-converter" export { normalizeArgs, validateArgs } from "./look-at-arguments" @@ -41,22 +47,58 @@ export function createLookAt(ctx: PluginInput): ToolDefinition { let mimeType: string let filePart: { type: "file"; mime: string; url: string; filename: string } + let tempFilePath: string | null = null + let tempFilesToCleanup: string[] = [] - if (imageData) { - mimeType = inferMimeTypeFromBase64(imageData) - filePart = { - type: "file", - mime: mimeType, - url: `data:${mimeType};base64,${extractBase64Data(imageData)}`, - filename: `clipboard-image.${mimeType.split("/")[1] || "png"}`, - } - } else if (filePath) { + try { + if (imageData) { + mimeType = inferMimeTypeFromBase64(imageData) + + let finalBase64Data = extractBase64Data(imageData) + let finalMimeType = mimeType + + if (needsConversion(mimeType)) { + log(`[look_at] Detected unsupported Base64 format: ${mimeType}, converting to JPEG...`) + try { + const { base64, tempFiles } = convertBase64ImageToJpeg(finalBase64Data, mimeType) + finalBase64Data = base64 + finalMimeType = "image/jpeg" + tempFilesToCleanup = tempFiles + log(`[look_at] Base64 conversion successful`) + } catch (conversionError) { + log(`[look_at] Base64 conversion failed: ${conversionError}`) + return `Error: Failed to convert Base64 image format. ${conversionError}` + } + } + + filePart = { + type: "file", + mime: finalMimeType, + url: `data:${finalMimeType};base64,${finalBase64Data}`, + filename: `clipboard-image.${finalMimeType.split("/")[1] || "png"}`, + } + } else if (filePath) { mimeType = inferMimeTypeFromFilePath(filePath) + + let actualFilePath = filePath + if (needsConversion(mimeType)) { + log(`[look_at] Detected unsupported format: ${mimeType}, converting to JPEG...`) + try { + tempFilePath = convertImageToJpeg(filePath, mimeType) + actualFilePath = tempFilePath + mimeType = "image/jpeg" + log(`[look_at] Conversion successful: ${tempFilePath}`) + } catch (conversionError) { + log(`[look_at] Conversion failed: ${conversionError}`) + return `Error: Failed to convert image format. ${conversionError}` + } + } + filePart = { type: "file", mime: mimeType, - url: pathToFileURL(filePath).href, - filename: basename(filePath), + url: pathToFileURL(actualFilePath).href, + filename: basename(actualFilePath), } } else { return "Error: Must provide either 'file_path' or 'image_data'." @@ -149,8 +191,14 @@ Original error: ${createResult.error}` return "Error: No response from multimodal-looker agent" } - log(`[look_at] Got response, length: ${responseText.length}`) - return responseText + log(`[look_at] Got response, length: ${responseText.length}`) + return responseText + } finally { + if (tempFilePath) { + cleanupConvertedImage(tempFilePath) + } + tempFilesToCleanup.forEach(file => cleanupConvertedImage(file)) + } }, }) } diff --git a/src/tools/lsp/AGENTS.md b/src/tools/lsp/AGENTS.md index 13968fff6..ff43d92e0 100644 --- a/src/tools/lsp/AGENTS.md +++ b/src/tools/lsp/AGENTS.md @@ -1,6 +1,6 @@ # src/tools/lsp/ — LSP Tool Implementations -**Generated:** 2026-02-24 +**Generated:** 2026-03-02 ## OVERVIEW diff --git a/src/tools/skill-mcp/builtin-mcp-hint.test.ts b/src/tools/skill-mcp/builtin-mcp-hint.test.ts new file mode 100644 index 000000000..6f96b339d --- /dev/null +++ b/src/tools/skill-mcp/builtin-mcp-hint.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "bun:test" + +import { SkillMcpManager } from "../../features/skill-mcp-manager" +import { createSkillMcpTool } from "./tools" + +const mockContext = { + sessionID: "test-session", + messageID: "msg-1", + agent: "test-agent", + directory: "/test", + worktree: "/test", + abort: new AbortController().signal, + metadata: () => {}, + ask: async () => {}, +} + +describe("skill_mcp builtin MCP hint", () => { + it("returns builtin hint for context7", async () => { + const tool = createSkillMcpTool({ + manager: new SkillMcpManager(), + getLoadedSkills: () => [], + getSessionID: () => "session", + }) + + await expect( + tool.execute({ mcp_name: "context7", tool_name: "resolve-library-id" }, mockContext), + ).rejects.toThrow(/builtin MCP/) + + await expect( + tool.execute({ mcp_name: "context7", tool_name: "resolve-library-id" }, mockContext), + ).rejects.toThrow(/context7_resolve-library-id/) + }) + + it("keeps skill-loading hint for unknown MCP names", async () => { + const tool = createSkillMcpTool({ + manager: new SkillMcpManager(), + getLoadedSkills: () => [], + getSessionID: () => "session", + }) + + await expect( + tool.execute({ mcp_name: "unknown-mcp", tool_name: "x" }, mockContext), + ).rejects.toThrow(/Load the skill first/) + }) +}) diff --git a/src/tools/skill-mcp/constants.ts b/src/tools/skill-mcp/constants.ts index 4df4f4d40..e2d13cf0b 100644 --- a/src/tools/skill-mcp/constants.ts +++ b/src/tools/skill-mcp/constants.ts @@ -1,3 +1,9 @@ export const SKILL_MCP_TOOL_NAME = "skill_mcp" export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.` + +export const BUILTIN_MCP_TOOL_HINTS: Record = { + context7: ["context7_resolve-library-id", "context7_query-docs"], + websearch: ["websearch_web_search_exa"], + grep_app: ["grep_app_searchGitHub"], +} diff --git a/src/tools/skill-mcp/tools.ts b/src/tools/skill-mcp/tools.ts index 96dddaa75..9791501fe 100644 --- a/src/tools/skill-mcp/tools.ts +++ b/src/tools/skill-mcp/tools.ts @@ -1,5 +1,5 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" -import { SKILL_MCP_DESCRIPTION } from "./constants" +import { BUILTIN_MCP_TOOL_HINTS, SKILL_MCP_DESCRIPTION } from "./constants" import type { SkillMcpArgs } from "./types" import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager" import type { LoadedSkill } from "../../features/opencode-skill-loader/types" @@ -71,6 +71,16 @@ function formatAvailableMcps(skills: LoadedSkill[]): string { return mcps.length > 0 ? mcps.join("\n") : " (none found)" } +function formatBuiltinMcpHint(mcpName: string): string | null { + const nativeTools = BUILTIN_MCP_TOOL_HINTS[mcpName] + if (!nativeTools) return null + return ( + `"${mcpName}" is a builtin MCP, not a skill MCP.\n` + + `Use the native tools directly:\n` + + nativeTools.map((toolName) => ` - ${toolName}`).join("\n") + ) +} + function parseArguments(argsJson: string | Record | undefined): Record { if (!argsJson) return {} if (typeof argsJson === "object" && argsJson !== null) { @@ -132,6 +142,11 @@ export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition const found = findMcpServer(args.mcp_name, skills) if (!found) { + const builtinHint = formatBuiltinMcpHint(args.mcp_name) + if (builtinHint) { + throw new Error(builtinHint) + } + throw new Error( `MCP server "${args.mcp_name}" not found.\n\n` + `Available MCP servers in loaded skills:\n` + diff --git a/src/tools/skill/tools.test.ts b/src/tools/skill/tools.test.ts index d4f2d01f6..e64a20fb4 100644 --- a/src/tools/skill/tools.test.ts +++ b/src/tools/skill/tools.test.ts @@ -464,7 +464,7 @@ describe("skill tool - ordering and priority", () => { const tool = createSkillTool({ skills, commands }) //#then: should include priority info - expect(tool.description).toContain("Priority: project > user > opencode > builtin") + expect(tool.description).toContain("Priority: project > user > opencode > builtin/plugin") expect(tool.description).toContain("Skills listed before commands") }) diff --git a/src/tools/skill/tools.ts b/src/tools/skill/tools.ts index 044776909..4bdfb42a0 100644 --- a/src/tools/skill/tools.ts +++ b/src/tools/skill/tools.ts @@ -16,6 +16,7 @@ const scopePriority: Record = { user: 3, opencode: 2, "opencode-project": 2, + plugin: 1, config: 1, builtin: 1, } @@ -89,7 +90,7 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[]) } if (allItems.length > 0) { - lines.push(`\n\nPriority: project > user > opencode > builtin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n`) + lines.push(`\n\nPriority: project > user > opencode > builtin/plugin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n`) } return TOOL_DESCRIPTION_PREFIX + lines.join("") @@ -195,7 +196,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition const getCommands = (): CommandInfo[] => { if (cachedCommands) return cachedCommands - cachedCommands = discoverCommandsSync() + cachedCommands = discoverCommandsSync(undefined, { + pluginsEnabled: options.pluginsEnabled, + enabledPluginsOverride: options.enabledPluginsOverride, + }) return cachedCommands } diff --git a/src/tools/skill/types.ts b/src/tools/skill/types.ts index 4fd48d6c7..579eb69cc 100644 --- a/src/tools/skill/types.ts +++ b/src/tools/skill/types.ts @@ -33,4 +33,8 @@ export interface SkillLoadOptions { /** Git master configuration for watermark/co-author settings */ gitMasterConfig?: GitMasterConfig disabledSkills?: Set + /** Include Claude marketplace plugin commands in discovery (default: true) */ + pluginsEnabled?: boolean + /** Override plugin enablement from Claude settings by plugin key */ + enabledPluginsOverride?: Record } diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts new file mode 100644 index 000000000..05e49ab3d --- /dev/null +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -0,0 +1,160 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test" +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { discoverCommandsSync } from "./command-discovery" + +const ENV_KEYS = [ + "CLAUDE_CONFIG_DIR", + "CLAUDE_PLUGINS_HOME", + "CLAUDE_SETTINGS_PATH", + "OPENCODE_CONFIG_DIR", +] as const + +type EnvKey = (typeof ENV_KEYS)[number] +type EnvSnapshot = Record + +function writePluginFixture(baseDir: string): { projectDir: string } { + const projectDir = join(baseDir, "project") + const claudeConfigDir = join(baseDir, "claude-config") + const pluginsHome = join(claudeConfigDir, "plugins") + const settingsPath = join(claudeConfigDir, "settings.json") + const opencodeConfigDir = join(baseDir, "opencode-config") + const pluginInstallPath = join(baseDir, "installed-plugins", "daplug") + const pluginKey = "daplug@1.0.0" + + mkdirSync(projectDir, { recursive: true }) + mkdirSync(join(pluginInstallPath, ".claude-plugin"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "commands"), { recursive: true }) + mkdirSync(join(pluginInstallPath, "skills", "plugin-plan"), { recursive: true }) + + writeFileSync( + join(pluginInstallPath, ".claude-plugin", "plugin.json"), + JSON.stringify({ name: "daplug", version: "1.0.0" }, null, 2), + ) + writeFileSync( + join(pluginInstallPath, "commands", "run-prompt.md"), + `--- +description: Run prompt from daplug +--- +Execute daplug prompt flow. +`, + ) + writeFileSync( + join(pluginInstallPath, "skills", "plugin-plan", "SKILL.md"), + `--- +name: plugin-plan +description: Plan work from daplug skill +--- +Build a plan from plugin skill context. +`, + ) + + mkdirSync(pluginsHome, { recursive: true }) + writeFileSync( + join(pluginsHome, "installed_plugins.json"), + JSON.stringify( + { + version: 2, + plugins: { + [pluginKey]: [ + { + scope: "user", + installPath: pluginInstallPath, + version: "1.0.0", + installedAt: "2026-01-01T00:00:00.000Z", + lastUpdated: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }, + null, + 2, + ), + ) + + mkdirSync(claudeConfigDir, { recursive: true }) + writeFileSync( + settingsPath, + JSON.stringify( + { + enabledPlugins: { + [pluginKey]: true, + }, + }, + null, + 2, + ), + ) + mkdirSync(opencodeConfigDir, { recursive: true }) + + process.env.CLAUDE_CONFIG_DIR = claudeConfigDir + process.env.CLAUDE_PLUGINS_HOME = pluginsHome + process.env.CLAUDE_SETTINGS_PATH = settingsPath + process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir + + return { projectDir } +} + +describe("slashcommand command discovery plugin integration", () => { + let tempDir = "" + let projectDir = "" + let envSnapshot: EnvSnapshot + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "omo-command-discovery-test-")) + envSnapshot = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, + CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + } + const setup = writePluginFixture(tempDir) + projectDir = setup.projectDir + }) + + afterEach(() => { + for (const key of ENV_KEYS) { + const previousValue = envSnapshot[key] + if (previousValue === undefined) { + delete process.env[key] + } else { + process.env[key] = previousValue + } + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + it("discovers marketplace plugin commands and skills as command items", () => { + const commands = discoverCommandsSync(projectDir, { pluginsEnabled: true }) + const names = commands.map(command => command.name) + + expect(names).toContain("daplug:run-prompt") + expect(names).toContain("daplug:plugin-plan") + + const pluginCommand = commands.find(command => command.name === "daplug:run-prompt") + const pluginSkill = commands.find(command => command.name === "daplug:plugin-plan") + + expect(pluginCommand?.scope).toBe("plugin") + expect(pluginSkill?.scope).toBe("plugin") + }) + + it("omits marketplace plugin commands when plugins are disabled", () => { + const commands = discoverCommandsSync(projectDir, { pluginsEnabled: false }) + const names = commands.map(command => command.name) + + expect(names).not.toContain("daplug:run-prompt") + expect(names).not.toContain("daplug:plugin-plan") + }) + + it("honors plugins_override by disabling overridden plugin keys", () => { + const commands = discoverCommandsSync(projectDir, { + pluginsEnabled: true, + enabledPluginsOverride: { "daplug@1.0.0": false }, + }) + const names = commands.map(command => command.name) + + expect(names).not.toContain("daplug:run-prompt") + expect(names).not.toContain("daplug:plugin-plan") + }) +}) diff --git a/src/tools/slashcommand/command-discovery.ts b/src/tools/slashcommand/command-discovery.ts index d06990036..57182f020 100644 --- a/src/tools/slashcommand/command-discovery.ts +++ b/src/tools/slashcommand/command-discovery.ts @@ -5,8 +5,18 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import { loadBuiltinCommands } from "../../features/builtin-commands" +import { + discoverInstalledPlugins, + loadPluginCommands, + loadPluginSkillsAsCommands, +} from "../../features/claude-code-plugin-loader" import type { CommandInfo, CommandMetadata, CommandScope } from "./types" +export interface CommandDiscoveryOptions { + pluginsEnabled?: boolean + enabledPluginsOverride?: Record +} + function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] { if (!existsSync(commandsDir)) return [] @@ -48,7 +58,38 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm return commands } -export function discoverCommandsSync(directory?: string): CommandInfo[] { +function discoverPluginCommands(options?: CommandDiscoveryOptions): CommandInfo[] { + if (options?.pluginsEnabled === false) { + return [] + } + + const { plugins } = discoverInstalledPlugins({ + enabledPluginsOverride: options?.enabledPluginsOverride, + }) + + const pluginDefinitions = { + ...loadPluginCommands(plugins), + ...loadPluginSkillsAsCommands(plugins), + } + + return Object.entries(pluginDefinitions).map(([name, definition]) => ({ + name, + metadata: { + name, + description: definition.description || "", + model: definition.model, + agent: definition.agent, + subtask: definition.subtask, + }, + content: definition.template, + scope: "plugin", + })) +} + +export function discoverCommandsSync( + directory?: string, + options?: CommandDiscoveryOptions, +): CommandInfo[] { const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(directory ?? process.cwd(), ".claude", "commands") @@ -59,6 +100,7 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] { const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode") const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project") const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project") + const pluginCommands = discoverPluginCommands(options) const builtinCommandsMap = loadBuiltinCommands() const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map((command) => ({ @@ -81,5 +123,6 @@ export function discoverCommandsSync(directory?: string): CommandInfo[] { ...opencodeProjectCommands, ...opencodeGlobalCommands, ...builtinCommands, + ...pluginCommands, ] } diff --git a/src/tools/slashcommand/types.ts b/src/tools/slashcommand/types.ts index 090e12178..af3935ef3 100644 --- a/src/tools/slashcommand/types.ts +++ b/src/tools/slashcommand/types.ts @@ -1,6 +1,6 @@ import type { LazyContentLoader } from "../../features/opencode-skill-loader" -export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" +export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project" | "plugin" export interface CommandMetadata { name: string