Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1c794e63e | ||
|
|
4692809b42 | ||
|
|
8961026285 | ||
|
|
d8b29da15f | ||
|
|
2b2160b43e | ||
|
|
60bbeb7304 | ||
|
|
f1b2f6f3f7 | ||
|
|
e9a3d579b3 | ||
|
|
c6c149ebb8 | ||
|
|
728eaaeb44 | ||
|
|
9271f827dd | ||
|
|
3a0d7e8dc3 | ||
|
|
aec5624122 | ||
|
|
53537a9a90 | ||
|
|
6b560ebf9e | ||
|
|
ca8ec494a3 | ||
|
|
3be722b3b1 | ||
|
|
d779a48a30 | ||
|
|
3166cffd02 | ||
|
|
3c32ae0449 | ||
|
|
bc782ca4d4 | ||
|
|
917bba9d1b | ||
|
|
7e5a657f06 | ||
|
|
bda44a5128 | ||
|
|
161a864ea3 | ||
|
|
93d3acce89 | ||
|
|
f63bf52a6e | ||
|
|
25e436a4aa | ||
|
|
1f64920453 | ||
|
|
4c7215404e | ||
|
|
d3999d79df | ||
|
|
b8f15affdb | ||
|
|
04576c306c | ||
|
|
e450e4f903 | ||
|
|
11d0005eb5 | ||
|
|
2224183b5c | ||
|
|
f468effd47 | ||
|
|
b8d7723f0a | ||
|
|
b3864d6398 | ||
|
|
b7f7cb4341 | ||
|
|
b2e8eecd09 | ||
|
|
6cfaac97b2 | ||
|
|
77e99d8b68 | ||
|
|
02e1043227 | ||
|
|
617d7f4f67 | ||
|
|
955ce710d9 | ||
|
|
8ff9c24623 | ||
|
|
bd3a3bcfb9 | ||
|
|
291f41f7f9 | ||
|
|
11b883da6c | ||
|
|
48cb2033e2 | ||
|
|
8842a9139f | ||
|
|
ca31796336 | ||
|
|
e1f6b822f1 | ||
|
|
a644d38623 | ||
|
|
a459813888 | ||
|
|
18e941b6be | ||
|
|
86ac39fb78 | ||
|
|
7621aada79 | ||
|
|
9800d1ecb0 | ||
|
|
0fbf863d00 | ||
|
|
71ac09bb63 | ||
|
|
ddf878e53c | ||
|
|
8886879bd0 | ||
|
|
f08d4ecdda | ||
|
|
8049ceb947 | ||
|
|
a298a2f063 | ||
|
|
ddc52bfd31 | ||
|
|
38b40bca04 | ||
|
|
169ccb6b05 | ||
|
|
d8137c0c90 | ||
|
|
81a2317f51 | ||
|
|
708d15ebcc | ||
|
|
80297f890e | ||
|
|
ce7478cde7 | ||
|
|
8d0fa97b72 | ||
|
|
8e349aad7e | ||
|
|
1712907057 | ||
|
|
d66e39a887 | ||
|
|
ace2688186 | ||
|
|
bf31e7289e | ||
|
|
7b8204924a | ||
|
|
d099b0255f |
82
AGENTS.md
82
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-02-03T16:10:30+09:00
|
||||
**Commit:** d7679e14
|
||||
**Generated:** 2026-02-06T18:30:00+09:00
|
||||
**Commit:** c6c149e
|
||||
**Branch:** dev
|
||||
|
||||
---
|
||||
@@ -120,40 +120,45 @@ This is an **international open-source project**. To ensure accessibility and ma
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3 Flash). 34 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 11 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash). 40+ lifecycle hooks, 25+ tools (LSP, AST-Grep, delegation), 11 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 34 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # 20+ tools - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Background agents, Claude Code compat - see src/features/AGENTS.md
|
||||
│ ├── shared/ # 66 cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (788 lines)
|
||||
├── script/ # build-schema.ts, build-binaries.ts
|
||||
├── packages/ # 11 platform-specific binaries
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 40+ lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # 25+ tools - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Background agents, skills, Claude Code compat - see src/features/AGENTS.md
|
||||
│ ├── shared/ # 66 cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
|
||||
│ ├── config/ # Zod schema (schema.ts 455 lines), TypeScript types
|
||||
│ ├── plugin-handlers/ # Plugin config loading (config-handler.ts 501 lines)
|
||||
│ ├── index.ts # Main plugin entry (924 lines)
|
||||
│ ├── plugin-config.ts # Config loading orchestration
|
||||
│ └── plugin-state.ts # Model cache state
|
||||
├── script/ # build-schema.ts, build-binaries.ts, publish.ts
|
||||
├── packages/ # 11 platform-specific binaries
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` |
|
||||
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` in utils.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| Add MCP | `src/mcp/` | Create config, add to `createBuiltinMcps()` |
|
||||
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
|
||||
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts (1418 lines) |
|
||||
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (757 lines) |
|
||||
| Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts (1556 lines) |
|
||||
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (770 lines) |
|
||||
| Delegation | `src/tools/delegate-task/` | Category routing (executor.ts 983 lines) |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
@@ -165,7 +170,7 @@ oh-my-opencode/
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests - fix the code
|
||||
- Test file: `*.test.ts` alongside source (100 test files)
|
||||
- Test file: `*.test.ts` alongside source (100+ test files)
|
||||
- BDD comments: `//#given`, `//#when`, `//#then`
|
||||
|
||||
## CONVENTIONS
|
||||
@@ -175,7 +180,7 @@ oh-my-opencode/
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern via index.ts
|
||||
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
|
||||
- **Testing**: BDD comments, 100 test files
|
||||
- **Testing**: BDD comments, 100+ test files
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS
|
||||
@@ -204,14 +209,17 @@ oh-my-opencode/
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
|
||||
| Hephaestus | openai/gpt-5.2-codex | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.2-codex, no fallback) |
|
||||
| Sisyphus | anthropic/claude-opus-4-6 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
|
||||
| Hephaestus | openai/gpt-5.3-codex | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
|
||||
| Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| oracle | openai/gpt-5.2 | Consultation, debugging |
|
||||
| librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) |
|
||||
| explore | xai/grok-code-fast-1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Prometheus | anthropic/claude-opus-4-6 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Metis | anthropic/claude-opus-4-6 | Pre-planning analysis (temp 0.3, fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Momus | openai/gpt-5.2 | Plan validation (temp 0.1, fallback: claude-opus-4-6) |
|
||||
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | Category-spawned executor (temp 0.1) |
|
||||
|
||||
## COMMANDS
|
||||
|
||||
@@ -219,7 +227,7 @@ oh-my-opencode/
|
||||
bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun test # 100 test files
|
||||
bun test # 100+ test files
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -233,30 +241,38 @@ bun test # 100 test files
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/features/builtin-skills/skills.ts` | 1729 | Skill definitions |
|
||||
| `src/features/background-agent/manager.ts` | 1418 | Task lifecycle, concurrency |
|
||||
| `src/agents/prometheus-prompt.ts` | 1283 | Planning agent prompt |
|
||||
| `src/tools/delegate-task/tools.ts` | 1135 | Category-based delegation |
|
||||
| `src/hooks/atlas/index.ts` | 757 | Orchestrator hook |
|
||||
| `src/index.ts` | 788 | Main plugin entry |
|
||||
| `src/features/background-agent/manager.ts` | 1556 | Task lifecycle, concurrency |
|
||||
| `src/features/builtin-skills/skills/git-master.ts` | 1107 | Git master skill definition |
|
||||
| `src/tools/delegate-task/executor.ts` | 983 | Category-based delegation executor |
|
||||
| `src/index.ts` | 924 | Main plugin entry |
|
||||
| `src/tools/lsp/client.ts` | 803 | LSP client operations |
|
||||
| `src/hooks/atlas/index.ts` | 770 | Orchestrator hook |
|
||||
| `src/tools/background-task/tools.ts` | 734 | Background task tools |
|
||||
| `src/cli/config-manager.ts` | 667 | JSONC config parsing |
|
||||
| `src/features/skill-mcp-manager/manager.ts` | 640 | MCP client lifecycle |
|
||||
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template |
|
||||
| `src/agents/hephaestus.ts` | 618 | Autonomous deep worker agent |
|
||||
| `src/tools/delegate-task/constants.ts` | 552 | Delegation constants |
|
||||
| `src/cli/install.ts` | 542 | Interactive CLI installer |
|
||||
| `src/agents/sisyphus.ts` | 530 | Main orchestrator agent |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
Three-tier system:
|
||||
1. **Built-in**: websearch (Exa), context7 (docs), grep_app (GitHub)
|
||||
1. **Built-in**: websearch (Exa/Tavily), context7 (docs), grep_app (GitHub)
|
||||
2. **Claude Code compat**: .mcp.json with `${VAR}` expansion
|
||||
3. **Skill-embedded**: YAML frontmatter in skills
|
||||
|
||||
## CONFIG SYSTEM
|
||||
|
||||
- **Zod validation**: `src/config/schema.ts`
|
||||
- **Zod validation**: `src/config/schema.ts` (455 lines)
|
||||
- **JSONC support**: Comments, trailing commas
|
||||
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`)
|
||||
- **Loading**: `src/plugin-handlers/config-handler.ts` → merge → validate
|
||||
|
||||
## NOTES
|
||||
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **No linter/formatter**: No ESLint, Prettier, or Biome configured
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
90
bun.lock
90
bun.lock
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "oh-my-opencode",
|
||||
@@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.2.2",
|
||||
"oh-my-opencode-darwin-x64": "3.2.2",
|
||||
"oh-my-opencode-linux-arm64": "3.2.2",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.2",
|
||||
"oh-my-opencode-linux-x64": "3.2.2",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.2",
|
||||
"oh-my-opencode-windows-x64": "3.2.2",
|
||||
"oh-my-opencode-darwin-arm64": "3.2.3",
|
||||
"oh-my-opencode-darwin-x64": "3.2.3",
|
||||
"oh-my-opencode-linux-arm64": "3.2.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.3",
|
||||
"oh-my-opencode-linux-x64": "3.2.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.3",
|
||||
"oh-my-opencode-windows-x64": "3.2.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -44,41 +44,41 @@
|
||||
"@code-yeongyu/comment-checker",
|
||||
],
|
||||
"packages": {
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.40.5", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.5", "@ast-grep/cli-darwin-x64": "0.40.5", "@ast-grep/cli-linux-arm64-gnu": "0.40.5", "@ast-grep/cli-linux-x64-gnu": "0.40.5", "@ast-grep/cli-win32-arm64-msvc": "0.40.5", "@ast-grep/cli-win32-ia32-msvc": "0.40.5", "@ast-grep/cli-win32-x64-msvc": "0.40.5" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-yVXL7Gz0WIHerQLf+MVaVSkhIhidtWReG5akNVr/JS9OVCVkSdz7gWm7H8jVv2M9OO1tauuG76K3UaRGBPu5lQ=="],
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.40.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.0", "@ast-grep/cli-darwin-x64": "0.40.0", "@ast-grep/cli-linux-arm64-gnu": "0.40.0", "@ast-grep/cli-linux-x64-gnu": "0.40.0", "@ast-grep/cli-win32-arm64-msvc": "0.40.0", "@ast-grep/cli-win32-ia32-msvc": "0.40.0", "@ast-grep/cli-win32-x64-msvc": "0.40.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L8AkflsfI2ZP70yIdrwqvjR02ScCuRmM/qNGnJWUkOFck+e6gafNVJ4e4jjGQlEul+dNdBpx36+O2Op629t47A=="],
|
||||
|
||||
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T9CzwJ1GqQhnANdsu6c7iT1akpvTVMK+AZrxnhIPv33Ze5hrXUUkqan+j4wUAukRJDqU7u94EhXLSLD+5tcJ8g=="],
|
||||
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UehY2MMUkdJbsriP7NKc6+uojrqPn7d1Cl0em+WAkee7Eij81VdyIjRsRxtZSLh440ZWQBHI3PALZ9RkOO8pKQ=="],
|
||||
|
||||
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ez9b2zKvXU8f4ghhjlqYvbx6tWCKJTuVlNVqDDfjqwwhGeiTYfnzMlSVat4ElYRMd21gLtXZIMy055v2f21Ztg=="],
|
||||
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RFDJ2ZxUbT0+grntNlOLJx7wa9/ciVCeaVtQpQy8WJJTvXvkY0etl8Qlh2TmO2x2yr+i0Z6aMJi4IG/Yx5ghTQ=="],
|
||||
|
||||
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VXa2L1IEYD66AMb0GuG7VlMMbPmEGoJUySWDcwSZo/D9neiry3MJ41LQR5oTG2HyhIPBsf9umrXnmuRq66BviA=="],
|
||||
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4p55gnTQ1mMFCyqjtM7bH9SB9r16mkwXtUcJQGX1YgFG4WD+QG8rC4GwSuNNZcdlYaOQuTWrgUEQ9z5K06UXfg=="],
|
||||
|
||||
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-GQC5162eIOWXR2eQQ6Knzg7/8Trp5E1ODJkaErf0IubdQrZBGqj5AAcQPcWgPbbnmktjIp0H4NraPpOJ9eJ22A=="],
|
||||
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u2MXFceuwvrO+OQ6zFGoJ6wbATXn46HWwW79j4UPrXYJzVl97jRyjJOIQTJOzTflsk02fjP98DQkfvbXt2dl3Q=="],
|
||||
|
||||
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-YiZdnQZsSlXQTMsZJop/Ux9MmUGfuRvC2x/UbFgrt5OBSYxND+yoiMc0WcA3WG+wU+tt4ZkB5HUea3r/IkOLYA=="],
|
||||
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-E/I1xpF/RQL2fo1CQsQfTxyDLnChsbZ+ERrQHKuF1FI4WrkaPOBibpqda60QgVmUcgOGZyZ/GRb3iKEVWPsQNQ=="],
|
||||
|
||||
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-MHkCxCITVTr8sY9CcVqNKbfUzMa3Hc6IilGXad0Clnw2vNmPfWqSky+hU/UTerr5YHWwWfAVURH7ANZgirtx0Q=="],
|
||||
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-9h12OQu1BR0GxHEtT+Z4QkJk3LLWLiKwjBkjXUGlASHYDPTyLcs85KwDLeFHs4BwarF8TDdF+KySvB9WPGl/nQ=="],
|
||||
|
||||
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/MJ5un7yxlClaaxou9eYl+Kr2xr/yTtYtTq5aLBWjPWA6dmmJ1nAJgx5zKHVuplFXFBrFDQk3paEgAETMTGcrA=="],
|
||||
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-n2+3WynEWFHhXg6KDgjwWQ0UEtIvqUITFbKEk5cDkUYrzYhg/A6kj0qauPwRbVMoJms49vtsNpLkzzqyunio5g=="],
|
||||
|
||||
"@ast-grep/napi": ["@ast-grep/napi@0.40.5", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.5", "@ast-grep/napi-darwin-x64": "0.40.5", "@ast-grep/napi-linux-arm64-gnu": "0.40.5", "@ast-grep/napi-linux-arm64-musl": "0.40.5", "@ast-grep/napi-linux-x64-gnu": "0.40.5", "@ast-grep/napi-linux-x64-musl": "0.40.5", "@ast-grep/napi-win32-arm64-msvc": "0.40.5", "@ast-grep/napi-win32-ia32-msvc": "0.40.5", "@ast-grep/napi-win32-x64-msvc": "0.40.5" } }, "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A=="],
|
||||
"@ast-grep/napi": ["@ast-grep/napi@0.40.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.0", "@ast-grep/napi-darwin-x64": "0.40.0", "@ast-grep/napi-linux-arm64-gnu": "0.40.0", "@ast-grep/napi-linux-arm64-musl": "0.40.0", "@ast-grep/napi-linux-x64-gnu": "0.40.0", "@ast-grep/napi-linux-x64-musl": "0.40.0", "@ast-grep/napi-win32-arm64-msvc": "0.40.0", "@ast-grep/napi-win32-ia32-msvc": "0.40.0", "@ast-grep/napi-win32-x64-msvc": "0.40.0" } }, "sha512-tq6nO/8KwUF/mHuk1ECaAOSOlz2OB/PmygnvprJzyAHGRVzdcffblaOOWe90M9sGz5MAasXoF+PTcayQj9TKKA=="],
|
||||
|
||||
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ=="],
|
||||
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZMjl5yLhKjxdwbqEEdMizgQdWH2NrWsM6Px+JuGErgCDe6Aedq9yurEPV7veybGdLVJQhOah6htlSflXxjHnYA=="],
|
||||
|
||||
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA=="],
|
||||
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-f9Ol5oQKNRMBkvDtzBK1WiNn2/3eejF2Pn9xwTj7PhXuSFseedOspPYllxQo0gbwUlw/DJqGFTce/jarhR/rBw=="],
|
||||
|
||||
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w=="],
|
||||
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tO+VW5GDhT9jGkKOK+3b8+ohKjC98WTzn7wSskd/myyhK3oYL1WTKqCm07WSYBZOJvb3z+WaX+wOUrc4bvtyQ=="],
|
||||
|
||||
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA=="],
|
||||
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A=="],
|
||||
|
||||
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q=="],
|
||||
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA=="],
|
||||
|
||||
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg=="],
|
||||
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ=="],
|
||||
|
||||
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug=="],
|
||||
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w=="],
|
||||
|
||||
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg=="],
|
||||
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0JkdBZi5l9vZhGEO38A1way0LmLRDU5Vos6MXrLIOVkymmzDTDlCdY394J1LMmmsfwWcyJg6J7Yv2dw41MCxDQ=="],
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ=="],
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
|
||||
|
||||
@@ -86,17 +86,17 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
|
||||
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="],
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.47", "", { "dependencies": { "@opencode-ai/sdk": "1.1.47", "zod": "4.1.8" } }, "sha512-gNMPz72altieDfLhUw3VAT1xbduKi3w3wZ57GLeS7qU9W474HdvdIiLBnt2Xq3U7Ko0/0tvK3nzCker6IIDqmQ=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.19", "", { "dependencies": { "@opencode-ai/sdk": "1.1.19", "zod": "4.1.8" } }, "sha512-Q6qBEjHb/dJMEw4BUqQxEswTMxCCHUpFMMb6jR8HTTs8X/28XRkKt5pHNPA82GU65IlSoPRph+zd8LReBDN53Q=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.47", "", {}, "sha512-s3PBHwk1sP6Zt/lJxIWSBWZ1TnrI1nFxSP97LCODUytouAQgbygZ1oDH7O2sGMBEuGdA8B1nNSPla0aRSN3IpA=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.19", "", {}, "sha512-XhZhFuvlLCqDpvNtUEjOsi/wvFj3YCXb1dySp+OONQRMuHlorNYnNa7P2A2ntKuhRdGT1Xt5na0nFzlUyNw+4A=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
|
||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
@@ -184,11 +184,11 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="],
|
||||
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
@@ -226,19 +226,19 @@
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.2", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KyfoWcANfcvpfanrrX+Wc8vH8vr9mvr7dJMHBe2bkvuhdtHnLHOG18hQwLg6jk4HhdoZAeBEmkolOsK2k4XajA=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Doc9xQCj5Jmx3PzouBIfvDwmfWM94Y9Q9IngFqOjrVpfBef9V/WIH0PlhJU6ps4BKGey8Nf2afFq3UE06Z63Hg=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.2", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ajZ1E36Ixwdz6rvSUKUI08M2xOaNIl1ZsdVjknZTrPRtct9xgS+BEFCoSCov9bnV/9DrZD3mlZtO/+FFDbseUg=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-w7lO0Hn/AlLCHe33KPbje83Js2h5weDWVMuopEs6d3pi/1zkRDBEhCi63S4J0d0EKod9kEPQA6ojtdVJ4J39zQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ItJsYfigXcOa8/ejTjopC4qk5BCeYioMQ693kPTpeYHK3ByugTjJk8aamE7bHlVnmrdgWldz91QFzaP82yOAdg=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m1tS1jRLO2Svm5NuetK3BAgdAR8b2GkiIfMFoIYsLJTPmzIkXaigAYkFq+BXCs5JAbRmPmvjndz9cuCddnPADQ=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.2", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/TvjYe/Kb//ZSHnJzgRj0QPKpS5Y2nermVTSaMTGS2btObXQyQWzuphDhsVRu60SVrNLbflHzfuTdqb3avDjyA=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Q/0AGtOuUFGNGIX8F6iD5W8c2spbjrqVBPt0B7laQSwnScKs/BI+TvM6HRE37vhoWg+fzhAX3QYJ2H9Un9FYrg=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ka5j+tjuQkNnpESVzcTzW5tZMlBhOfP9F12+UaR72cIcwFpSoLMBp84rV6R0vXM0zUcrrN7mPeW66DvQ6A0XQQ=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RIAyoj2XbT8vH++5fPUkdO+D1tfqxh+iWto7CqWr1TgbABbBJljGk91HJgS9xjnxyCQJEpFhTmO7NMHKJcZOWQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.2", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ISl0sTNShKCgPFO+rsDqEDsvVHQAMfOSAxO0KuWbHFKaH+KaRV4d3N/ihgxZ2M94CZjJLzZEuln+6kLZ93cvzQ=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-nnQK3y7R4DrBvqdqRGbujL2oAAQnVVb23JHUbJPQ6YxrRRGWpLOVGvK5c16ykSFEUPl8eZDmi1ON/R4opKLOUw=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.2", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-KeiJLQvJuZ+UYf/+eMsQXvCiHDRPk6tD15lL+qruLvU19va62JqMNvTuOv97732uF19iG0ZMiiVhqIMbSyVPqQ=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mt8E/TkpaCp04pvzwntT8x8TaqXDt3zCD5X2eA8ZZMrb5ofNr5HyG5G4SFXrUh+Ez3b/3YXpNWv6f6rnAlk1Dg=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
@@ -310,10 +310,8 @@
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ A Category is an agent configuration preset optimized for specific domains.
|
||||
| Category | Default Model | Use Cases |
|
||||
|----------|---------------|-----------|
|
||||
| `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
|
||||
| `deep` | `openai/gpt-5.2-codex` (medium) | Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding. |
|
||||
| `ultrabrain` | `openai/gpt-5.3-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
|
||||
| `deep` | `openai/gpt-5.3-codex` (medium) | Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding. |
|
||||
| `artistry` | `google/gemini-3-pro` (max) | Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing |
|
||||
|
||||
### Usage
|
||||
@@ -159,7 +159,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `description` | string | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
|
||||
| `model` | string | AI model ID to use (e.g., `anthropic/claude-opus-4-5`) |
|
||||
| `model` | string | AI model ID to use (e.g., `anthropic/claude-opus-4-6`) |
|
||||
| `variant` | string | Model variant (e.g., `max`, `xhigh`) |
|
||||
| `temperature` | number | Creativity level (0.0 ~ 2.0). Lower is more deterministic. |
|
||||
| `top_p` | number | Nucleus sampling parameter (0.0 ~ 1.0) |
|
||||
@@ -191,7 +191,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
|
||||
|
||||
// 3. Configure thinking model and restrict tools
|
||||
"deep-reasoning": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"thinking": {
|
||||
"type": "enabled",
|
||||
"budgetTokens": 32000
|
||||
|
||||
@@ -693,7 +693,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
"google": 10
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"anthropic/claude-opus-4-5": 2,
|
||||
"anthropic/claude-opus-4-6": 2,
|
||||
"google/gemini-3-flash": 10
|
||||
}
|
||||
}
|
||||
@@ -705,7 +705,7 @@ Configure concurrency limits for background agent tasks. This controls how many
|
||||
| `defaultConcurrency` | - | Default maximum concurrent background tasks for all providers/models |
|
||||
| `staleTimeoutMs` | `180000` | Stale timeout in milliseconds - interrupt tasks with no activity for this duration (minimum: 60000 = 1 minute) |
|
||||
| `providerConcurrency` | - | Per-provider concurrency limits. Keys are provider names (e.g., `anthropic`, `openai`, `google`) |
|
||||
| `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-5`). Overrides provider limits. |
|
||||
| `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-6`). Overrides provider limits. |
|
||||
|
||||
**Priority Order**: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency`
|
||||
|
||||
@@ -725,11 +725,11 @@ All 7 categories come with optimal model defaults, but **you must configure them
|
||||
| Category | Built-in Default Model | Description |
|
||||
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
|
||||
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
|
||||
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `ultrabrain` | `openai/gpt-5.3-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
|
||||
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
|
||||
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
|
||||
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required |
|
||||
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
|
||||
|
||||
### ⚠️ Critical: Model Resolution Priority
|
||||
@@ -768,7 +768,7 @@ All 7 categories come with optimal model defaults, but **you must configure them
|
||||
"model": "google/gemini-3-pro-preview"
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh"
|
||||
},
|
||||
"artistry": {
|
||||
@@ -782,7 +782,7 @@ All 7 categories come with optimal model defaults, but **you must configure them
|
||||
"model": "anthropic/claude-sonnet-4-5"
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max"
|
||||
},
|
||||
"writing": {
|
||||
@@ -870,9 +870,9 @@ At runtime, Oh My OpenCode uses a 3-step resolution process to determine which m
|
||||
│ │ anthropic → github-copilot → opencode → antigravity │ │
|
||||
│ │ │ │ │ │ │ │
|
||||
│ │ ▼ ▼ ▼ ▼ │ │
|
||||
│ │ Try: anthropic/claude-opus-4-5 │ │
|
||||
│ │ Try: github-copilot/claude-opus-4-5 │ │
|
||||
│ │ Try: opencode/claude-opus-4-5 │ │
|
||||
│ │ Try: anthropic/claude-opus-4-6 │ │
|
||||
│ │ Try: github-copilot/claude-opus-4-6 │ │
|
||||
│ │ Try: opencode/claude-opus-4-6 │ │
|
||||
│ │ ... │ │
|
||||
│ │ │ │
|
||||
│ │ Found in available models? → Return matched model │ │
|
||||
@@ -894,13 +894,13 @@ Each agent has a defined provider priority chain. The system tries providers in
|
||||
|
||||
| Agent | Model (no prefix) | Provider Priority Chain |
|
||||
|-------|-------------------|-------------------------|
|
||||
| **Sisyphus** | `claude-opus-4-5` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
|
||||
| **Sisyphus** | `claude-opus-4-6` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
|
||||
| **oracle** | `gpt-5.2` | openai → google → anthropic |
|
||||
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic |
|
||||
| **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode |
|
||||
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → kimi-for-coding → anthropic → opencode |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Metis (Plan Consultant)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Prometheus (Planner)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Metis (Plan Consultant)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
|
||||
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google |
|
||||
| **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google |
|
||||
|
||||
@@ -911,12 +911,12 @@ Categories follow the same resolution logic:
|
||||
| Category | Model (no prefix) | Provider Priority Chain |
|
||||
|----------|-------------------|-------------------------|
|
||||
| **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan |
|
||||
| **ultrabrain** | `gpt-5.2-codex` | openai → google → anthropic |
|
||||
| **deep** | `gpt-5.2-codex` | openai → anthropic → google |
|
||||
| **ultrabrain** | `gpt-5.3-codex` | openai → google → anthropic |
|
||||
| **deep** | `gpt-5.3-codex` | openai → anthropic → google |
|
||||
| **artistry** | `gemini-3-pro` | google → anthropic → openai |
|
||||
| **quick** | `claude-haiku-4-5` | anthropic → google → opencode |
|
||||
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google |
|
||||
| **unspecified-high** | `claude-opus-4-5` | anthropic → openai → google |
|
||||
| **unspecified-high** | `claude-opus-4-6` | anthropic → openai → google |
|
||||
| **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai |
|
||||
|
||||
### Checking Your Configuration
|
||||
@@ -949,7 +949,7 @@ Override any agent or category model in `oh-my-opencode.json`:
|
||||
},
|
||||
"categories": {
|
||||
"visual-engineering": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
"model": "anthropic/claude-opus-4-6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ Oh-My-OpenCode provides 11 specialized AI agents. Each has distinct expertise, o
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro. |
|
||||
| **Hephaestus** | `openai/gpt-5.2-codex` | **The Legitimate Craftsman.** Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires gpt-5.2-codex (no fallback - only activates when this model is available). |
|
||||
| **Sisyphus** | `anthropic/claude-opus-4-6` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro. |
|
||||
| **Hephaestus** | `openai/gpt-5.3-codex` | **The Legitimate Craftsman.** Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires gpt-5.3-codex (no fallback - only activates when this model is available). |
|
||||
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
|
||||
| **librarian** | `zai-coding-plan/glm-4.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: glm-4.7-free → claude-sonnet-4-5. |
|
||||
| **explore** | `anthropic/claude-haiku-4-5` | Fast codebase exploration and contextual grep. Fallback: gpt-5-mini → gpt-5-nano. |
|
||||
@@ -21,9 +21,9 @@ Oh-My-OpenCode provides 11 specialized AI agents. Each has distinct expertise, o
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| **Prometheus** | `anthropic/claude-opus-4-5` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Metis** | `anthropic/claude-opus-4-5` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-5 → gemini-3-pro. |
|
||||
| **Prometheus** | `anthropic/claude-opus-4-6` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Metis** | `anthropic/claude-opus-4-6` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
|
||||
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-6 → gemini-3-pro. |
|
||||
|
||||
### Invoking Agents
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ When GitHub Copilot is the best available provider, oh-my-opencode uses these mo
|
||||
|
||||
| Agent | Model |
|
||||
| ------------- | -------------------------------- |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.6` |
|
||||
| **Oracle** | `github-copilot/gpt-5.2` |
|
||||
| **Explore** | `opencode/gpt-5-nano` |
|
||||
| **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback |
|
||||
@@ -218,13 +218,13 @@ If Z.ai is the only provider available, all agents will use GLM models:
|
||||
|
||||
#### OpenCode Zen
|
||||
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/glm-4.7-free`.
|
||||
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/glm-4.7-free`.
|
||||
|
||||
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
|
||||
|
||||
| Agent | Model |
|
||||
| ------------- | -------------------------------- |
|
||||
| **Sisyphus** | `opencode/claude-opus-4-5` |
|
||||
| **Sisyphus** | `opencode/claude-opus-4-6` |
|
||||
| **Oracle** | `opencode/gpt-5.2` |
|
||||
| **Explore** | `opencode/gpt-5-nano` |
|
||||
| **Librarian** | `opencode/glm-4.7-free` |
|
||||
|
||||
@@ -277,7 +277,7 @@ This "boulder pushing" mechanism is why the system is named after Sisyphus.
|
||||
```typescript
|
||||
// OLD: Model name creates distributional bias
|
||||
delegate_task(agent="gpt-5.2", prompt="...") // Model knows its limitations
|
||||
delegate_task(agent="claude-opus-4.5", prompt="...") // Different self-perception
|
||||
delegate_task(agent="claude-opus-4.6", prompt="...") // Different self-perception
|
||||
```
|
||||
|
||||
**The Solution: Semantic Categories:**
|
||||
|
||||
@@ -275,7 +275,7 @@ flowchart TD
|
||||
|
||||
### 🔮 Prometheus (The Planner)
|
||||
|
||||
- **Model**: `anthropic/claude-opus-4-5`
|
||||
- **Model**: `anthropic/claude-opus-4-6`
|
||||
- **Role**: Strategic planning, requirements interviews, work plan creation
|
||||
- **Constraint**: **READ-ONLY**. Can only create/modify markdown files within `.sisyphus/` directory.
|
||||
- **Characteristic**: Never writes code directly, focuses solely on "how to do it".
|
||||
|
||||
357
issue-1501-analysis.md
Normal file
357
issue-1501-analysis.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Issue #1501 분석 보고서: ULW Mode PLAN AGENT 무한루프
|
||||
|
||||
## 📋 이슈 요약
|
||||
|
||||
**증상:**
|
||||
- ULW (ultrawork) mode에서 PLAN AGENT가 무한루프에 빠짐
|
||||
- 분석/탐색 완료 후 plan만 계속 생성
|
||||
- 1분마다 매우 작은 토큰으로 요청 발생
|
||||
|
||||
**예상 동작:**
|
||||
- 탐색 완료 후 solution document 생성
|
||||
|
||||
---
|
||||
|
||||
## 🔍 근본 원인 분석
|
||||
|
||||
### 파일: `src/tools/delegate-task/constants.ts`
|
||||
|
||||
#### 문제의 핵심
|
||||
|
||||
`PLAN_AGENT_SYSTEM_PREPEND` (constants.ts 234-269행)에 구조적 결함이 있었습니다:
|
||||
|
||||
1. **Interactive Mode 가정**
|
||||
```
|
||||
2. After gathering context, ALWAYS present:
|
||||
- Uncertainties: List of unclear points
|
||||
- Clarifying Questions: Specific questions to resolve uncertainties
|
||||
|
||||
3. ITERATE until ALL requirements are crystal clear:
|
||||
- Do NOT proceed to planning until you have 100% clarity
|
||||
- Ask the user to confirm your understanding
|
||||
```
|
||||
|
||||
2. **종료 조건 없음**
|
||||
- "100% clarity" 요구는 객관적 측정 불가능
|
||||
- 사용자 확인 요청은 ULW mode에서 불가능
|
||||
- 무한루프로 이어짐
|
||||
|
||||
3. **ULW Mode 미감지**
|
||||
- Subagent로 실행되는 경우를 구분하지 않음
|
||||
- 항상 interactive mode로 동작 시도
|
||||
|
||||
### 왜 무한루프가 발생했는가?
|
||||
|
||||
```
|
||||
ULW Mode 시작
|
||||
→ Sisyphus가 Plan Agent 호출 (subagent)
|
||||
→ Plan Agent: "100% clarity 필요"
|
||||
→ Clarifying questions 생성
|
||||
→ 사용자 없음 (subagent)
|
||||
→ 다시 plan 생성 시도
|
||||
→ "여전히 unclear"
|
||||
→ 무한루프 반복
|
||||
```
|
||||
|
||||
**핵심:** Plan Agent는 사용자와 대화하도록 설계되었지만, ULW mode에서는 사용자가 없는 subagent로 실행됨.
|
||||
|
||||
---
|
||||
|
||||
## ✅ 적용된 수정 방안
|
||||
|
||||
### 수정 내용 (constants.ts)
|
||||
|
||||
#### 1. SUBAGENT MODE DETECTION 섹션 추가
|
||||
|
||||
```typescript
|
||||
SUBAGENT MODE DETECTION (CRITICAL):
|
||||
If you received a detailed prompt with gathered context from a parent orchestrator (e.g., Sisyphus):
|
||||
- You are running as a SUBAGENT
|
||||
- You CANNOT directly interact with the user
|
||||
- DO NOT ask clarifying questions - proceed with available information
|
||||
- Make reasonable assumptions for minor ambiguities
|
||||
- Generate the plan based on the provided context
|
||||
```
|
||||
|
||||
#### 2. Context Gathering Protocol 수정
|
||||
|
||||
```diff
|
||||
- 1. Launch background agents to gather context:
|
||||
+ 1. Launch background agents to gather context (ONLY if not already provided):
|
||||
```
|
||||
|
||||
**효과:** 이미 Sisyphus가 context를 수집한 경우 중복 방지
|
||||
|
||||
#### 3. Clarifying Questions → Assumptions
|
||||
|
||||
```diff
|
||||
- 2. After gathering context, ALWAYS present:
|
||||
- - Uncertainties: List of unclear points
|
||||
- - Clarifying Questions: Specific questions
|
||||
+ 2. After gathering context, assess clarity:
|
||||
+ - User Request Summary: Concise restatement
|
||||
+ - Assumptions Made: List any assumptions for unclear points
|
||||
```
|
||||
|
||||
**효과:** 질문 대신 가정 사항 문서화
|
||||
|
||||
#### 4. 무한루프 방지 - 명확한 종료 조건
|
||||
|
||||
```diff
|
||||
- 3. ITERATE until ALL requirements are crystal clear:
|
||||
- - Do NOT proceed to planning until you have 100% clarity
|
||||
- - Ask the user to confirm your understanding
|
||||
- - Resolve every ambiguity before generating the work plan
|
||||
+ 3. PROCEED TO PLAN GENERATION when:
|
||||
+ - Core objective is understood (even if some details are ambiguous)
|
||||
+ - You have gathered context via explore/librarian (or context was provided)
|
||||
+ - You can make reasonable assumptions for remaining ambiguities
|
||||
+
|
||||
+ DO NOT loop indefinitely waiting for perfect clarity.
|
||||
+ DOCUMENT assumptions in the plan so they can be validated during execution.
|
||||
```
|
||||
|
||||
**효과:**
|
||||
- "100% clarity" 요구 제거
|
||||
- 객관적인 진입 조건 제공
|
||||
- 무한루프 명시적 금지
|
||||
- Assumptions를 plan에 문서화하여 실행 중 검증 가능
|
||||
|
||||
#### 5. 철학 변경
|
||||
|
||||
```diff
|
||||
- REMEMBER: Vague requirements lead to failed implementations.
|
||||
+ REMEMBER: A plan with documented assumptions is better than no plan.
|
||||
```
|
||||
|
||||
**효과:** Perfectionism → Pragmatism
|
||||
|
||||
---
|
||||
|
||||
## 🎯 해결 메커니즘
|
||||
|
||||
### Before (무한루프)
|
||||
|
||||
```
|
||||
Plan Agent 시작
|
||||
↓
|
||||
Context gathering
|
||||
↓
|
||||
Requirements 명확한가?
|
||||
↓ NO
|
||||
Clarifying questions 생성
|
||||
↓
|
||||
사용자 응답 대기 (없음)
|
||||
↓
|
||||
다시 plan 시도
|
||||
↓
|
||||
(무한 반복)
|
||||
```
|
||||
|
||||
### After (정상 종료)
|
||||
|
||||
```
|
||||
Plan Agent 시작
|
||||
↓
|
||||
Subagent mode 감지?
|
||||
↓ YES
|
||||
Context 이미 있음? → YES
|
||||
↓
|
||||
Core objective 이해? → YES
|
||||
↓
|
||||
Reasonable assumptions 가능? → YES
|
||||
↓
|
||||
Plan 생성 (assumptions 문서화)
|
||||
↓
|
||||
완료 ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 영향 분석
|
||||
|
||||
### 해결되는 문제
|
||||
|
||||
1. **ULW mode 무한루프** ✓
|
||||
2. **Sisyphus에서 Plan Agent 호출 시 블로킹** ✓
|
||||
3. **작은 토큰 반복 요청** ✓
|
||||
4. **1분마다 재시도** ✓
|
||||
|
||||
### 부작용 없음
|
||||
|
||||
- Interactive mode (사용자와 직접 대화)는 여전히 작동
|
||||
- Subagent mode일 때만 다르게 동작
|
||||
- Backward compatibility 유지
|
||||
|
||||
### 추가 개선사항
|
||||
|
||||
- Assumptions를 plan에 명시적으로 문서화
|
||||
- Execution 중 validation 가능
|
||||
- 더 pragmatic한 workflow
|
||||
|
||||
---
|
||||
|
||||
## 🧪 검증 방법
|
||||
|
||||
### 테스트 시나리오
|
||||
|
||||
1. **ULW mode에서 Plan Agent 호출**
|
||||
```bash
|
||||
oh-my-opencode run "Complex task requiring planning. ulw"
|
||||
```
|
||||
- 예상: Plan 생성 후 정상 종료
|
||||
- 확인: 무한루프 없음
|
||||
|
||||
2. **Interactive mode (변경 없어야 함)**
|
||||
```bash
|
||||
oh-my-opencode run --agent prometheus "Design X"
|
||||
```
|
||||
- 예상: Clarifying questions 여전히 가능
|
||||
- 확인: 사용자와 대화 가능
|
||||
|
||||
3. **Subagent context 제공 케이스**
|
||||
- 예상: Context gathering skip
|
||||
- 확인: 중복 탐색 없음
|
||||
|
||||
---
|
||||
|
||||
## 📝 수정된 파일
|
||||
|
||||
```
|
||||
src/tools/delegate-task/constants.ts
|
||||
```
|
||||
|
||||
### Diff Summary
|
||||
|
||||
```diff
|
||||
@@ -234,22 +234,32 @@ export const PLAN_AGENT_SYSTEM_PREPEND = `<system>
|
||||
+SUBAGENT MODE DETECTION (CRITICAL):
|
||||
+[subagent 감지 및 처리 로직]
|
||||
+
|
||||
MANDATORY CONTEXT GATHERING PROTOCOL:
|
||||
-1. Launch background agents to gather context:
|
||||
+1. Launch background agents (ONLY if not already provided):
|
||||
|
||||
-2. After gathering context, ALWAYS present:
|
||||
- - Uncertainties
|
||||
- - Clarifying Questions
|
||||
+2. After gathering context, assess clarity:
|
||||
+ - Assumptions Made
|
||||
|
||||
-3. ITERATE until ALL requirements are crystal clear:
|
||||
- - Do NOT proceed until 100% clarity
|
||||
- - Ask user to confirm
|
||||
+3. PROCEED TO PLAN GENERATION when:
|
||||
+ - Core objective understood
|
||||
+ - Context gathered
|
||||
+ - Reasonable assumptions possible
|
||||
+
|
||||
+ DO NOT loop indefinitely.
|
||||
+ DOCUMENT assumptions.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 권장 사항
|
||||
|
||||
### Immediate Actions
|
||||
|
||||
1. ✅ **수정 적용 완료** - constants.ts 업데이트됨
|
||||
2. ⏳ **테스트 수행** - ULW mode에서 동작 검증
|
||||
3. ⏳ **PR 생성** - code review 요청
|
||||
|
||||
### Future Improvements
|
||||
|
||||
1. **Subagent context 표준화**
|
||||
- Subagent로 호출 시 명시적 플래그 전달
|
||||
- `is_subagent: true` 파라미터 추가 고려
|
||||
|
||||
2. **Assumptions validation workflow**
|
||||
- Plan 실행 중 assumptions 검증 메커니즘
|
||||
- Incorrect assumptions 감지 시 재계획
|
||||
|
||||
3. **Timeout 메커니즘**
|
||||
- Plan Agent가 X분 이상 걸리면 강제 종료
|
||||
- Fallback plan 생성
|
||||
|
||||
4. **Monitoring 추가**
|
||||
- Plan Agent 실행 시간 측정
|
||||
- Iteration 횟수 로깅
|
||||
- 무한루프 조기 감지
|
||||
|
||||
---
|
||||
|
||||
## 📖 관련 코드 구조
|
||||
|
||||
### Call Stack
|
||||
|
||||
```
|
||||
Sisyphus (ULW mode)
|
||||
↓
|
||||
delegate_task(category="deep", ...)
|
||||
↓
|
||||
executor.ts: executeBackgroundContinuation()
|
||||
↓
|
||||
prompt-builder.ts: buildSystemContent()
|
||||
↓
|
||||
constants.ts: PLAN_AGENT_SYSTEM_PREPEND (문제 위치)
|
||||
↓
|
||||
Plan Agent 실행
|
||||
```
|
||||
|
||||
### Key Functions
|
||||
|
||||
1. **executor.ts:587** - `isPlanAgent()` 체크
|
||||
2. **prompt-builder.ts:11** - Plan Agent prepend 주입
|
||||
3. **constants.ts:234** - PLAN_AGENT_SYSTEM_PREPEND 정의
|
||||
|
||||
---
|
||||
|
||||
## 🎓 교훈
|
||||
|
||||
### Design Lessons
|
||||
|
||||
1. **Dual Mode Support**
|
||||
- Interactive vs Autonomous mode 구분 필수
|
||||
- Context 전달 방식 명확히
|
||||
|
||||
2. **Avoid Perfectionism in Agents**
|
||||
- "100% clarity" 같은 주관적 조건 지양
|
||||
- 명확한 객관적 종료 조건 필요
|
||||
|
||||
3. **Document Uncertainties**
|
||||
- 불확실성을 숨기지 말고 문서화
|
||||
- 실행 중 validation 가능하게
|
||||
|
||||
4. **Infinite Loop Prevention**
|
||||
- 모든 반복문에 명시적 종료 조건
|
||||
- Timeout 또는 max iteration 설정
|
||||
|
||||
---
|
||||
|
||||
## 🔗 참고 자료
|
||||
|
||||
- **Issue:** #1501 - [Bug]: ULW mode will 100% cause PLAN AGENT to get stuck
|
||||
- **Files Modified:** `src/tools/delegate-task/constants.ts`
|
||||
- **Related Concepts:** Ultrawork mode, Plan Agent, Subagent delegation
|
||||
- **Agent Architecture:** Sisyphus → Prometheus → Atlas workflow
|
||||
|
||||
---
|
||||
|
||||
## ✅ Conclusion
|
||||
|
||||
**Root Cause:** Plan Agent가 interactive mode를 가정했으나 ULW mode에서는 subagent로 실행되어 사용자 상호작용 불가능. "100% clarity" 요구로 무한루프 발생.
|
||||
|
||||
**Solution:** Subagent mode 감지 로직 추가, clarifying questions 제거, 명확한 종료 조건 제공, assumptions 문서화 방식 도입.
|
||||
|
||||
**Result:** ULW mode에서 Plan Agent가 정상적으로 plan 생성 후 종료. 무한루프 해결.
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Fixed
|
||||
**Tested:** ⏳ Pending
|
||||
**Deployed:** ⏳ Pending
|
||||
|
||||
**Analyst:** Sisyphus (oh-my-opencode ultrawork mode)
|
||||
**Date:** 2026-02-05
|
||||
**Session:** fast-ember
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"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",
|
||||
@@ -74,13 +74,13 @@
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.2.3",
|
||||
"oh-my-opencode-darwin-x64": "3.2.3",
|
||||
"oh-my-opencode-linux-arm64": "3.2.3",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.3",
|
||||
"oh-my-opencode-linux-x64": "3.2.3",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.3",
|
||||
"oh-my-opencode-windows-x64": "3.2.3"
|
||||
"oh-my-opencode-darwin-arm64": "3.2.4",
|
||||
"oh-my-opencode-darwin-x64": "3.2.4",
|
||||
"oh-my-opencode-linux-arm64": "3.2.4",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.4",
|
||||
"oh-my-opencode-linux-x64": "3.2.4",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.4",
|
||||
"oh-my-opencode-windows-x64": "3.2.4"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.2.3",
|
||||
"version": "3.2.4",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as z from "zod"
|
||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
|
||||
@@ -7,9 +8,8 @@ const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
|
||||
async function main() {
|
||||
console.log("Generating JSON Schema...")
|
||||
|
||||
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
|
||||
io: "input",
|
||||
target: "draft-7",
|
||||
const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema, {
|
||||
target: "draft7",
|
||||
})
|
||||
|
||||
const finalSchema = {
|
||||
|
||||
@@ -1183,6 +1183,30 @@
|
||||
"created_at": "2026-02-03T20:44:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1449
|
||||
},
|
||||
{
|
||||
"name": "BowTiedSwan",
|
||||
"id": 86532747,
|
||||
"comment_id": 3742668781,
|
||||
"created_at": "2026-01-13T08:05:00Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 741
|
||||
},
|
||||
{
|
||||
"name": "Mang-Joo",
|
||||
"id": 86056915,
|
||||
"comment_id": 3855493558,
|
||||
"created_at": "2026-02-05T18:41:49Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1526
|
||||
},
|
||||
{
|
||||
"name": "shaunmorris",
|
||||
"id": 579820,
|
||||
"comment_id": 3858265174,
|
||||
"created_at": "2026-02-06T06:23:24Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1541
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Model | `anthropic/claude-opus-4-5` |
|
||||
| Model | `anthropic/claude-opus-4-6` |
|
||||
| Max Tokens | `64000` |
|
||||
| Mode | `primary` |
|
||||
| Thinking | Budget: 32000 |
|
||||
|
||||
@@ -13,36 +13,50 @@
|
||||
## STRUCTURE
|
||||
```
|
||||
agents/
|
||||
├── atlas.ts # Master Orchestrator (holds todo list)
|
||||
├── sisyphus.ts # Main prompt (SF Bay Area engineer identity)
|
||||
├── hephaestus.ts # Autonomous Deep Worker (GPT 5.2 Codex, "The Legitimate Craftsman")
|
||||
├── sisyphus-junior.ts # Delegated task executor (category-spawned)
|
||||
├── atlas/ # Master Orchestrator (holds todo list)
|
||||
│ ├── index.ts
|
||||
│ ├── default.ts # Claude-optimized prompt (390 lines)
|
||||
│ ├── gpt.ts # GPT-optimized prompt (330 lines)
|
||||
│ └── utils.ts
|
||||
├── prometheus/ # Planning Agent (Interview/Consultant mode)
|
||||
│ ├── index.ts
|
||||
│ ├── plan-template.ts # Work plan structure (423 lines)
|
||||
│ ├── interview-mode.ts # Interview flow (335 lines)
|
||||
│ ├── plan-generation.ts
|
||||
│ ├── high-accuracy-mode.ts
|
||||
│ ├── identity-constraints.ts # Identity rules (301 lines)
|
||||
│ └── behavioral-summary.ts
|
||||
├── sisyphus-junior/ # Delegated task executor (category-spawned)
|
||||
│ ├── index.ts
|
||||
│ ├── default.ts
|
||||
│ └── gpt.ts
|
||||
├── sisyphus.ts # Main orchestrator prompt (530 lines)
|
||||
├── hephaestus.ts # Autonomous deep worker (618 lines, GPT 5.3 Codex)
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (GitHub CLI, Context7)
|
||||
├── explore.ts # Fast contextual grep (Grok Code Fast)
|
||||
├── librarian.ts # Multi-repo research (328 lines)
|
||||
├── explore.ts # Fast contextual grep
|
||||
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
|
||||
├── prometheus-prompt.ts # Planning (Interview/Consultant mode, 1283 lines)
|
||||
├── metis.ts # Pre-planning analysis (Gap detection)
|
||||
├── momus.ts # Plan reviewer (Ruthless fault-finding)
|
||||
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
|
||||
├── metis.ts # Pre-planning analysis (347 lines)
|
||||
├── momus.ts # Plan reviewer
|
||||
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (431 lines)
|
||||
├── types.ts # AgentModelConfig, AgentPromptMetadata
|
||||
├── utils.ts # createBuiltinAgents(), resolveModelWithFallback()
|
||||
├── utils.ts # createBuiltinAgents(), resolveModelWithFallback() (485 lines)
|
||||
└── index.ts # builtinAgents export
|
||||
```
|
||||
|
||||
## AGENT MODELS
|
||||
| Agent | Model | Temp | Purpose |
|
||||
|-------|-------|------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
|
||||
| Hephaestus | openai/gpt-5.2-codex | 0.1 | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.2-codex, no fallback) |
|
||||
| Sisyphus | anthropic/claude-opus-4-6 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
|
||||
| Hephaestus | openai/gpt-5.3-codex | 0.1 | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
|
||||
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
|
||||
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
|
||||
| explore | xai/grok-code-fast-1 | 0.1 | Fast contextual grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
|
||||
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Metis | anthropic/claude-opus-4-5 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-5) |
|
||||
| Prometheus | anthropic/claude-opus-4-6 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Metis | anthropic/claude-opus-4-6 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
|
||||
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-6) |
|
||||
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
|
||||
|
||||
## HOW TO ADD
|
||||
@@ -59,15 +73,17 @@ agents/
|
||||
| explore | write, edit, task, delegate_task, call_omo_agent |
|
||||
| multimodal-looker | Allowlist: read only |
|
||||
| Sisyphus-Junior | task, delegate_task |
|
||||
| Atlas | task, call_omo_agent |
|
||||
|
||||
## PATTERNS
|
||||
- **Factory**: `createXXXAgent(model: string): AgentConfig`
|
||||
- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers.
|
||||
- **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`.
|
||||
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas.
|
||||
- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers
|
||||
- **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`
|
||||
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas
|
||||
- **Model-specific routing**: Atlas, Sisyphus-Junior have GPT vs Claude prompt variants
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **Trust reports**: NEVER trust "I'm done" - verify outputs.
|
||||
- **High temp**: Don't use >0.3 for code agents.
|
||||
- **Sequential calls**: Use `delegate_task` with `run_in_background` for exploration.
|
||||
- **Prometheus writing code**: Planner only - never implements.
|
||||
- **Trust reports**: NEVER trust "I'm done" - verify outputs
|
||||
- **High temp**: Don't use >0.3 for code agents
|
||||
- **Sequential calls**: Use `delegate_task` with `run_in_background` for exploration
|
||||
- **Prometheus writing code**: Planner only - never implements
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
|
||||
|
||||
export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
|
||||
@@ -56,21 +56,48 @@ export function buildSkillsSection(skills: AvailableSkill[]): string {
|
||||
return ""
|
||||
}
|
||||
|
||||
const skillRows = skills.map((s) => {
|
||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||
const customSkills = skills.filter((s) => s.location !== "plugin")
|
||||
|
||||
const builtinRows = builtinSkills.map((s) => {
|
||||
const shortDesc = s.description.split(".")[0] || s.description
|
||||
return `| \`${s.name}\` | ${shortDesc} |`
|
||||
})
|
||||
|
||||
const customRows = customSkills.map((s) => {
|
||||
const shortDesc = s.description.split(".")[0] || s.description
|
||||
const source = s.location === "project" ? "project" : "user"
|
||||
return `| \`${s.name}\` | ${shortDesc} | ${source} |`
|
||||
})
|
||||
|
||||
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**")
|
||||
|
||||
let skillsTable: string
|
||||
|
||||
if (customSkills.length > 0 && builtinSkills.length > 0) {
|
||||
skillsTable = `**Built-in Skills:**
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
${builtinRows.join("\n")}
|
||||
|
||||
${customSkillBlock}`
|
||||
} else if (customSkills.length > 0) {
|
||||
skillsTable = customSkillBlock
|
||||
} else {
|
||||
skillsTable = `| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
${builtinRows.join("\n")}`
|
||||
}
|
||||
|
||||
return `
|
||||
#### 3.2.2: Skill Selection (PREPEND TO PROMPT)
|
||||
|
||||
**Skills are specialized instructions that guide subagent behavior. Consider them alongside category selection.**
|
||||
|
||||
| Skill | When to Use |
|
||||
|-------|-------------|
|
||||
${skillRows.join("\n")}
|
||||
${skillsTable}
|
||||
|
||||
**MANDATORY: Evaluate ALL skills for relevance to your task.**
|
||||
**MANDATORY: Evaluate ALL skills (built-in AND user-installed) for relevance to your task.**
|
||||
|
||||
Read each skill's description and ask: "Does this skill's domain overlap with my task?"
|
||||
- If YES: INCLUDE in load_skills=[...]
|
||||
|
||||
205
src/agents/dynamic-agent-prompt-builder.test.ts
Normal file
205
src/agents/dynamic-agent-prompt-builder.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
buildCategorySkillsDelegationGuide,
|
||||
buildUltraworkSection,
|
||||
formatCustomSkillsBlock,
|
||||
type AvailableSkill,
|
||||
type AvailableCategory,
|
||||
type AvailableAgent,
|
||||
} from "./dynamic-agent-prompt-builder"
|
||||
|
||||
describe("buildCategorySkillsDelegationGuide", () => {
|
||||
const categories: AvailableCategory[] = [
|
||||
{ name: "visual-engineering", description: "Frontend, UI/UX" },
|
||||
{ name: "quick", description: "Trivial tasks" },
|
||||
]
|
||||
|
||||
const builtinSkills: AvailableSkill[] = [
|
||||
{ name: "playwright", description: "Browser automation via Playwright", location: "plugin" },
|
||||
{ name: "frontend-ui-ux", description: "Designer-turned-developer", location: "plugin" },
|
||||
]
|
||||
|
||||
const customUserSkills: AvailableSkill[] = [
|
||||
{ name: "react-19", description: "React 19 patterns and best practices", location: "user" },
|
||||
{ name: "tailwind-4", description: "Tailwind CSS v4 utilities", location: "user" },
|
||||
]
|
||||
|
||||
const customProjectSkills: AvailableSkill[] = [
|
||||
{ name: "our-design-system", description: "Internal design system components", location: "project" },
|
||||
]
|
||||
|
||||
it("should separate builtin and custom skills into distinct sections", () => {
|
||||
//#given: mix of builtin and custom skills
|
||||
const allSkills = [...builtinSkills, ...customUserSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should have separate sections
|
||||
expect(result).toContain("Built-in Skills")
|
||||
expect(result).toContain("User-Installed Skills")
|
||||
expect(result).toContain("HIGH PRIORITY")
|
||||
})
|
||||
|
||||
it("should include custom skill names in CRITICAL warning", () => {
|
||||
//#given: custom skills installed
|
||||
const allSkills = [...builtinSkills, ...customUserSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should mention custom skills by name in the warning
|
||||
expect(result).toContain('"react-19"')
|
||||
expect(result).toContain('"tailwind-4"')
|
||||
expect(result).toContain("CRITICAL")
|
||||
})
|
||||
|
||||
it("should show source column for custom skills (user vs project)", () => {
|
||||
//#given: both user and project custom skills
|
||||
const allSkills = [...builtinSkills, ...customUserSkills, ...customProjectSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should show source for each custom skill
|
||||
expect(result).toContain("| user |")
|
||||
expect(result).toContain("| project |")
|
||||
})
|
||||
|
||||
it("should not show custom skill section when only builtin skills exist", () => {
|
||||
//#given: only builtin skills
|
||||
const allSkills = [...builtinSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should not contain custom skill emphasis
|
||||
expect(result).not.toContain("User-Installed Skills")
|
||||
expect(result).not.toContain("HIGH PRIORITY")
|
||||
expect(result).toContain("Available Skills")
|
||||
})
|
||||
|
||||
it("should handle only custom skills (no builtins)", () => {
|
||||
//#given: only custom skills, no builtins
|
||||
const allSkills = [...customUserSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: should show custom skills with emphasis, no builtin section
|
||||
expect(result).toContain("User-Installed Skills")
|
||||
expect(result).toContain("HIGH PRIORITY")
|
||||
expect(result).not.toContain("Built-in Skills")
|
||||
})
|
||||
|
||||
it("should include priority note for custom skills in evaluation step", () => {
|
||||
//#given: custom skills present
|
||||
const allSkills = [...builtinSkills, ...customUserSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: evaluation section should mention user-installed priority
|
||||
expect(result).toContain("User-installed skills get PRIORITY")
|
||||
expect(result).toContain("INCLUDE it rather than omit it")
|
||||
})
|
||||
|
||||
it("should NOT include priority note when no custom skills", () => {
|
||||
//#given: only builtin skills
|
||||
const allSkills = [...builtinSkills]
|
||||
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
|
||||
|
||||
//#then: no priority note for custom skills
|
||||
expect(result).not.toContain("User-installed skills get PRIORITY")
|
||||
})
|
||||
|
||||
it("should return empty string when no categories and no skills", () => {
|
||||
//#given: no categories and no skills
|
||||
//#when: building the delegation guide
|
||||
const result = buildCategorySkillsDelegationGuide([], [])
|
||||
|
||||
//#then: should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildUltraworkSection", () => {
|
||||
const agents: AvailableAgent[] = []
|
||||
|
||||
it("should separate builtin and custom skills", () => {
|
||||
//#given: mix of builtin and custom skills
|
||||
const skills: AvailableSkill[] = [
|
||||
{ name: "playwright", description: "Browser automation", location: "plugin" },
|
||||
{ name: "react-19", description: "React 19 patterns", location: "user" },
|
||||
]
|
||||
|
||||
//#when: building ultrawork section
|
||||
const result = buildUltraworkSection(agents, [], skills)
|
||||
|
||||
//#then: should have separate sections
|
||||
expect(result).toContain("Built-in Skills")
|
||||
expect(result).toContain("User-Installed Skills")
|
||||
expect(result).toContain("HIGH PRIORITY")
|
||||
})
|
||||
|
||||
it("should not separate when only builtin skills", () => {
|
||||
//#given: only builtin skills
|
||||
const skills: AvailableSkill[] = [
|
||||
{ name: "playwright", description: "Browser automation", location: "plugin" },
|
||||
]
|
||||
|
||||
//#when: building ultrawork section
|
||||
const result = buildUltraworkSection(agents, [], skills)
|
||||
|
||||
//#then: should have single section
|
||||
expect(result).toContain("Built-in Skills")
|
||||
expect(result).not.toContain("User-Installed Skills")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatCustomSkillsBlock", () => {
|
||||
const customSkills: AvailableSkill[] = [
|
||||
{ name: "react-19", description: "React 19 patterns", location: "user" },
|
||||
{ name: "tailwind-4", description: "Tailwind v4", location: "project" },
|
||||
]
|
||||
|
||||
const customRows = customSkills.map((s) => {
|
||||
const source = s.location === "project" ? "project" : "user"
|
||||
return `| \`${s.name}\` | ${s.description} | ${source} |`
|
||||
})
|
||||
|
||||
it("should produce consistent output used by both builders", () => {
|
||||
//#given: custom skills and rows
|
||||
//#when: formatting with default header level
|
||||
const result = formatCustomSkillsBlock(customRows, customSkills)
|
||||
|
||||
//#then: contains all expected elements
|
||||
expect(result).toContain("User-Installed Skills (HIGH PRIORITY)")
|
||||
expect(result).toContain("CRITICAL")
|
||||
expect(result).toContain('"react-19"')
|
||||
expect(result).toContain('"tailwind-4"')
|
||||
expect(result).toContain("| user |")
|
||||
expect(result).toContain("| project |")
|
||||
})
|
||||
|
||||
it("should use #### header by default", () => {
|
||||
//#given: default header level
|
||||
const result = formatCustomSkillsBlock(customRows, customSkills)
|
||||
|
||||
//#then: uses markdown h4
|
||||
expect(result).toContain("#### User-Installed Skills")
|
||||
})
|
||||
|
||||
it("should use bold header when specified", () => {
|
||||
//#given: bold header level (used by Atlas)
|
||||
const result = formatCustomSkillsBlock(customRows, customSkills, "**")
|
||||
|
||||
//#then: uses bold instead of h4
|
||||
expect(result).toContain("**User-Installed Skills (HIGH PRIORITY):**")
|
||||
expect(result).not.toContain("#### User-Installed Skills")
|
||||
})
|
||||
})
|
||||
@@ -20,6 +20,7 @@ export interface AvailableSkill {
|
||||
export interface AvailableCategory {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
||||
@@ -166,6 +167,33 @@ export function buildDelegationTable(agents: AvailableAgent[]): string {
|
||||
return rows.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the "User-Installed Skills (HIGH PRIORITY)" block used across multiple agent prompts.
|
||||
* Extracted to avoid duplication between buildCategorySkillsDelegationGuide, buildSkillsSection, etc.
|
||||
*/
|
||||
export function formatCustomSkillsBlock(
|
||||
customRows: string[],
|
||||
customSkills: AvailableSkill[],
|
||||
headerLevel: "####" | "**" = "####"
|
||||
): string {
|
||||
const customSkillNames = customSkills.map((s) => `"${s.name}"`).join(", ")
|
||||
const header = headerLevel === "####"
|
||||
? `#### User-Installed Skills (HIGH PRIORITY)`
|
||||
: `**User-Installed Skills (HIGH PRIORITY):**`
|
||||
|
||||
return `${header}
|
||||
|
||||
**The user has installed these custom skills. They MUST be evaluated for EVERY delegation.**
|
||||
Subagents are STATELESS — they lose all custom knowledge unless you pass these skills via \`load_skills\`.
|
||||
|
||||
| Skill | Expertise Domain | Source |
|
||||
|-------|------------------|--------|
|
||||
${customRows.join("\n")}
|
||||
|
||||
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
|
||||
> The user installed ${customSkillNames} for a reason — USE THEM when the task overlaps with their domain.`
|
||||
}
|
||||
|
||||
export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string {
|
||||
if (categories.length === 0 && skills.length === 0) return ""
|
||||
|
||||
@@ -174,11 +202,44 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory
|
||||
return `| \`${c.name}\` | ${desc} |`
|
||||
})
|
||||
|
||||
const skillRows = skills.map((s) => {
|
||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||
const customSkills = skills.filter((s) => s.location !== "plugin")
|
||||
|
||||
const builtinRows = builtinSkills.map((s) => {
|
||||
const desc = s.description.split(".")[0] || s.description
|
||||
return `| \`${s.name}\` | ${desc} |`
|
||||
})
|
||||
|
||||
const customRows = customSkills.map((s) => {
|
||||
const desc = s.description.split(".")[0] || s.description
|
||||
const source = s.location === "project" ? "project" : "user"
|
||||
return `| \`${s.name}\` | ${desc} | ${source} |`
|
||||
})
|
||||
|
||||
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills)
|
||||
|
||||
let skillsSection: string
|
||||
|
||||
if (customSkills.length > 0 && builtinSkills.length > 0) {
|
||||
skillsSection = `#### Built-in Skills
|
||||
|
||||
| Skill | Expertise Domain |
|
||||
|-------|------------------|
|
||||
${builtinRows.join("\n")}
|
||||
|
||||
${customSkillBlock}`
|
||||
} else if (customSkills.length > 0) {
|
||||
skillsSection = customSkillBlock
|
||||
} else {
|
||||
skillsSection = `#### Available Skills (Domain Expertise Injection)
|
||||
|
||||
Skills inject specialized instructions into the subagent. Read the description to understand when each skill applies.
|
||||
|
||||
| Skill | Expertise Domain |
|
||||
|-------|------------------|
|
||||
${builtinRows.join("\n")}`
|
||||
}
|
||||
|
||||
return `### Category + Skills Delegation System
|
||||
|
||||
**delegate_task() combines categories and skills for optimal task execution.**
|
||||
@@ -191,13 +252,7 @@ Each category is configured with a model optimized for that domain. Read the des
|
||||
|----------|-------------------|
|
||||
${categoryRows.join("\n")}
|
||||
|
||||
#### Available Skills (Domain Expertise Injection)
|
||||
|
||||
Skills inject specialized instructions into the subagent. Read the description to understand when each skill applies.
|
||||
|
||||
| Skill | Expertise Domain |
|
||||
|-------|------------------|
|
||||
${skillRows.join("\n")}
|
||||
${skillsSection}
|
||||
|
||||
---
|
||||
|
||||
@@ -208,12 +263,15 @@ ${skillRows.join("\n")}
|
||||
- Match task requirements to category domain
|
||||
- Select the category whose domain BEST fits the task
|
||||
|
||||
**STEP 2: Evaluate ALL Skills**
|
||||
**STEP 2: Evaluate ALL Skills (Built-in AND User-Installed)**
|
||||
For EVERY skill listed above, ask yourself:
|
||||
> "Does this skill's expertise domain overlap with my task?"
|
||||
|
||||
- If YES → INCLUDE in \`load_skills=[...]\`
|
||||
- If NO → You MUST justify why (see below)
|
||||
${customSkills.length > 0 ? `
|
||||
> **User-installed skills get PRIORITY.** The user explicitly installed them for their workflow.
|
||||
> When in doubt about a user-installed skill, INCLUDE it rather than omit it.` : ""}
|
||||
|
||||
**STEP 3: Justify Omissions**
|
||||
|
||||
@@ -240,7 +298,7 @@ SKILL EVALUATION for "[skill-name]":
|
||||
\`\`\`typescript
|
||||
delegate_task(
|
||||
category="[selected-category]",
|
||||
load_skills=["skill-1", "skill-2"], // Include ALL relevant skills
|
||||
load_skills=["skill-1", "skill-2"], // Include ALL relevant skills — ESPECIALLY user-installed ones
|
||||
prompt="..."
|
||||
)
|
||||
\`\`\`
|
||||
@@ -328,12 +386,26 @@ export function buildUltraworkSection(
|
||||
}
|
||||
|
||||
if (skills.length > 0) {
|
||||
lines.push("**Skills** (combine with categories - EVALUATE ALL for relevance):")
|
||||
for (const skill of skills) {
|
||||
const shortDesc = skill.description.split(".")[0] || skill.description
|
||||
lines.push(`- \`${skill.name}\`: ${shortDesc}`)
|
||||
const builtinSkills = skills.filter((s) => s.location === "plugin")
|
||||
const customSkills = skills.filter((s) => s.location !== "plugin")
|
||||
|
||||
if (builtinSkills.length > 0) {
|
||||
lines.push("**Built-in Skills** (combine with categories):")
|
||||
for (const skill of builtinSkills) {
|
||||
const shortDesc = skill.description.split(".")[0] || skill.description
|
||||
lines.push(`- \`${skill.name}\`: ${shortDesc}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (customSkills.length > 0) {
|
||||
lines.push("**User-Installed Skills** (HIGH PRIORITY - user installed these for their workflow):")
|
||||
for (const skill of customSkills) {
|
||||
const shortDesc = skill.description.split(".")[0] || skill.description
|
||||
lines.push(`- \`${skill.name}\`: ${shortDesc}`)
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (agents.length > 0) {
|
||||
|
||||
@@ -142,6 +142,19 @@ You operate as a **Senior Staff Engineer** with deep expertise in:
|
||||
|
||||
You do not guess. You verify. You do not stop early. You complete.
|
||||
|
||||
## Core Principle (HIGHEST PRIORITY)
|
||||
|
||||
**KEEP GOING. SOLVE PROBLEMS. ASK ONLY WHEN TRULY IMPOSSIBLE.**
|
||||
|
||||
When blocked:
|
||||
1. Try a different approach (there's always another way)
|
||||
2. Decompose the problem into smaller pieces
|
||||
3. Challenge your assumptions
|
||||
4. Explore how others solved similar problems
|
||||
|
||||
Asking the user is the LAST resort after exhausting creative alternatives.
|
||||
Your job is to SOLVE problems, not report them.
|
||||
|
||||
## Hard Constraints (MUST READ FIRST - GPT 5.2 Constraint-First)
|
||||
|
||||
${hardBlocks}
|
||||
@@ -404,6 +417,13 @@ Only terminate your turn when you are SURE the problem is SOLVED.
|
||||
Autonomously resolve the query to the BEST of your ability.
|
||||
Do NOT guess. Do NOT ask unnecessary questions. Do NOT stop early.
|
||||
|
||||
**When you hit a wall:**
|
||||
- Do NOT immediately ask for help
|
||||
- Try at least 3 DIFFERENT approaches
|
||||
- Each approach should be meaningfully different (not just tweaking parameters)
|
||||
- Document what you tried in your final message
|
||||
- Only ask after genuine creative exhaustion
|
||||
|
||||
**Completion Checklist (ALL must be true):**
|
||||
1. User asked for X → X is FULLY implemented (not partial, not "basic version")
|
||||
2. X passes lsp_diagnostics (zero errors on ALL modified files)
|
||||
@@ -459,9 +479,9 @@ Do NOT guess. Do NOT ask unnecessary questions. Do NOT stop early.
|
||||
- Each update must include concrete outcome ("Found X", "Updated Y")
|
||||
|
||||
**Scope:**
|
||||
- Implement EXACTLY what user requests
|
||||
- No extra features, no embellishments
|
||||
- Simplest valid interpretation for ambiguous instructions
|
||||
- Implement what user requests
|
||||
- When blocked, autonomously try alternative approaches before asking
|
||||
- No unnecessary features, but solve blockers creatively
|
||||
</output_contract>
|
||||
|
||||
## Response Compaction (LONG CONTEXT HANDLING)
|
||||
@@ -545,21 +565,27 @@ When working on long sessions or complex multi-file tasks:
|
||||
2. Re-verify after EVERY fix attempt
|
||||
3. Never shotgun debug
|
||||
|
||||
### After 3 Consecutive Failures
|
||||
### After Failure (AUTONOMOUS RECOVERY)
|
||||
|
||||
1. **Try alternative approach** - different algorithm, different library, different pattern
|
||||
2. **Decompose** - break into smaller, independently solvable steps
|
||||
3. **Challenge assumptions** - what if your initial interpretation was wrong?
|
||||
4. **Explore more** - fire explore/librarian agents for similar problems solved elsewhere
|
||||
|
||||
### After 3 DIFFERENT Approaches Fail
|
||||
|
||||
1. **STOP** all edits
|
||||
2. **REVERT** to last working state
|
||||
3. **DOCUMENT** what failed
|
||||
3. **DOCUMENT** what you tried (all 3 approaches)
|
||||
4. **CONSULT** Oracle with full context
|
||||
5. If unresolved, **ASK USER**
|
||||
5. If Oracle cannot help, **ASK USER** with clear explanation of attempts
|
||||
|
||||
**Never**: Leave code broken, delete failing tests, continue hoping
|
||||
|
||||
## Soft Guidelines
|
||||
|
||||
- Prefer existing libraries over new dependencies
|
||||
- Prefer small, focused changes over large refactors
|
||||
- When uncertain about scope, ask`
|
||||
- Prefer small, focused changes over large refactors`
|
||||
}
|
||||
|
||||
export function createHephaestusAgent(
|
||||
|
||||
@@ -6,14 +6,14 @@ import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
import * as modelAvailability from "../shared/model-availability"
|
||||
import * as shared from "../shared"
|
||||
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
|
||||
|
||||
describe("createBuiltinAgents with model overrides", () => {
|
||||
test("Sisyphus with default model has thinking config when all models available", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"kimi-for-coding/k2p5",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-4.7",
|
||||
@@ -26,7 +26,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
|
||||
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
|
||||
} finally {
|
||||
@@ -81,7 +81,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
|
||||
test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
|
||||
// #given
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-5"
|
||||
const systemDefaultModel = "anthropic/claude-opus-4-6"
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
|
||||
@@ -91,7 +91,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -218,7 +218,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
])
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"kimi-for-coding/k2p5",
|
||||
"opencode/kimi-k2.5-free",
|
||||
"zai-coding-plan/glm-4.7",
|
||||
@@ -232,7 +232,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -240,12 +240,13 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents with requiresModel gating", () => {
|
||||
test("hephaestus is not created when gpt-5.2-codex is unavailable", async () => {
|
||||
// #given
|
||||
describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () => {
|
||||
test("hephaestus is not created when no required provider is connected", async () => {
|
||||
// #given - only anthropic models available, not in hephaestus requiresProvider
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-5"])
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
|
||||
|
||||
try {
|
||||
// #when
|
||||
@@ -255,13 +256,48 @@ describe("createBuiltinAgents with requiresModel gating", () => {
|
||||
expect(agents.hephaestus).toBeUndefined()
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
cacheSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("hephaestus is created when gpt-5.2-codex is available", async () => {
|
||||
// #given
|
||||
test("hephaestus is created when openai provider is connected", async () => {
|
||||
// #given - openai provider has models available
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.2-codex"])
|
||||
new Set(["openai/gpt-5.3-codex"])
|
||||
)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
||||
|
||||
// #then
|
||||
expect(agents.hephaestus).toBeDefined()
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("hephaestus is created when github-copilot provider is connected", async () => {
|
||||
// #given - github-copilot provider has models available
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["github-copilot/gpt-5.3-codex"])
|
||||
)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
|
||||
|
||||
// #then
|
||||
expect(agents.hephaestus).toBeDefined()
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("hephaestus is created when opencode provider is connected", async () => {
|
||||
// #given - opencode provider has models available
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["opencode/gpt-5.3-codex"])
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -286,20 +322,20 @@ describe("createBuiltinAgents with requiresModel gating", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.hephaestus).toBeDefined()
|
||||
expect(agents.hephaestus.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.hephaestus.model).toBe("openai/gpt-5.3-codex")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("hephaestus is created when explicit config provided even if model unavailable", async () => {
|
||||
test("hephaestus is created when explicit config provided even if provider unavailable", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-5"])
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
)
|
||||
const overrides = {
|
||||
hephaestus: { model: "anthropic/claude-opus-4-5" },
|
||||
hephaestus: { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -318,7 +354,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
test("sisyphus is created when at least one fallback model is available", async () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["anthropic/claude-opus-4-5"])
|
||||
new Set(["anthropic/claude-opus-4-6"])
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -343,7 +379,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
} finally {
|
||||
cacheSpy.mockRestore()
|
||||
fetchSpy.mockRestore()
|
||||
@@ -354,7 +390,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
// #given
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
|
||||
const overrides = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -368,11 +404,12 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("sisyphus is not created when no fallback model is available (unrelated model only)", async () => {
|
||||
test("sisyphus is not created when no fallback model is available and provider not connected", async () => {
|
||||
// #given - only openai/gpt-5.2 available, not in sisyphus fallback chain
|
||||
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
|
||||
new Set(["openai/gpt-5.2"])
|
||||
)
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue([])
|
||||
|
||||
try {
|
||||
// #when
|
||||
@@ -382,13 +419,14 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
expect(agents.sisyphus).toBeUndefined()
|
||||
} finally {
|
||||
fetchSpy.mockRestore()
|
||||
cacheSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
const { buildAgent } = require("./utils")
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-5"
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-6"
|
||||
|
||||
beforeEach(() => {
|
||||
clearSkillCache()
|
||||
@@ -534,7 +572,7 @@ describe("buildAgent with category and skills", () => {
|
||||
const agent = buildAgent(source["test-agent"], TEST_MODEL)
|
||||
|
||||
// #then - category's built-in model and skills are applied
|
||||
expect(agent.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agent.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(agent.variant).toBe("xhigh")
|
||||
expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
|
||||
expect(agent.prompt).toContain("Task description")
|
||||
@@ -647,9 +685,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
// #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(agents.oracle.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
@@ -716,9 +754,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
// #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
|
||||
expect(agents.sisyphus).toBeDefined()
|
||||
expect(agents.sisyphus.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.sisyphus.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(agents.sisyphus.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
@@ -731,9 +769,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
|
||||
// #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
|
||||
expect(agents.atlas).toBeDefined()
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(agents.atlas.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(agents.atlas.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, migrateAgentConfig } from "../shared"
|
||||
import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, isAnyProviderConnected, migrateAgentConfig } from "../shared"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
|
||||
import { createBuiltinSkills } from "../features/builtin-skills"
|
||||
@@ -394,13 +394,13 @@ export async function createBuiltinAgents(
|
||||
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
|
||||
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
|
||||
|
||||
const hasRequiredModel =
|
||||
!hephaestusRequirement?.requiresModel ||
|
||||
const hasRequiredProvider =
|
||||
!hephaestusRequirement?.requiresProvider ||
|
||||
hasHephaestusExplicitConfig ||
|
||||
isFirstRunNoCache ||
|
||||
(availableModels.size > 0 && isModelAvailable(hephaestusRequirement.requiresModel, availableModels))
|
||||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
|
||||
|
||||
if (hasRequiredModel) {
|
||||
if (hasRequiredProvider) {
|
||||
let hephaestusResolution = applyModelResolution({
|
||||
userModel: hephaestusOverride?.model,
|
||||
requirement: hephaestusRequirement,
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
CLI entry: `bunx oh-my-opencode`. 4 commands with Commander.js + @clack/prompts TUI.
|
||||
CLI entry: `bunx oh-my-opencode`. 5 commands with Commander.js + @clack/prompts TUI.
|
||||
|
||||
**Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version
|
||||
**Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version, mcp-oauth
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry (4 commands)
|
||||
├── index.ts # Commander.js entry (5 commands)
|
||||
├── install.ts # Interactive TUI (542 lines)
|
||||
├── config-manager.ts # JSONC parsing (667 lines)
|
||||
├── types.ts # InstallArgs, InstallConfig
|
||||
├── model-fallback.ts # Model fallback configuration
|
||||
├── types.ts # InstallArgs, InstallConfig
|
||||
├── doctor/
|
||||
│ ├── index.ts # Doctor entry
|
||||
│ ├── runner.ts # Check orchestration
|
||||
│ ├── formatter.ts # Colored output
|
||||
│ ├── constants.ts # Check IDs, symbols
|
||||
│ ├── types.ts # CheckResult, CheckDefinition (114 lines)
|
||||
│ ├── types.ts # CheckResult, CheckDefinition
|
||||
│ └── checks/ # 14 checks, 23 files
|
||||
│ ├── version.ts # OpenCode + plugin version
|
||||
│ ├── config.ts # JSONC validity, Zod
|
||||
@@ -28,10 +28,11 @@ cli/
|
||||
│ ├── dependencies.ts # AST-Grep, Comment Checker
|
||||
│ ├── lsp.ts # LSP connectivity
|
||||
│ ├── mcp.ts # MCP validation
|
||||
│ ├── model-resolution.ts # Model resolution check
|
||||
│ ├── model-resolution.ts # Model resolution check (323 lines)
|
||||
│ └── gh.ts # GitHub CLI
|
||||
├── run/
|
||||
│ └── index.ts # Session launcher
|
||||
│ ├── index.ts # Session launcher
|
||||
│ └── events.ts # CLI run events (325 lines)
|
||||
├── mcp-oauth/
|
||||
│ └── index.ts # MCP OAuth flow
|
||||
└── get-local-version/
|
||||
@@ -46,6 +47,7 @@ cli/
|
||||
| `doctor` | 14 health checks for diagnostics |
|
||||
| `run` | Launch session with todo enforcement |
|
||||
| `get-local-version` | Version detection and update check |
|
||||
| `mcp-oauth` | MCP OAuth authentication flow |
|
||||
|
||||
## DOCTOR CATEGORIES (14 Checks)
|
||||
|
||||
|
||||
@@ -75,26 +75,26 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -103,7 +103,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -113,7 +113,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"writing": {
|
||||
@@ -137,26 +137,26 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -165,18 +165,18 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"writing": {
|
||||
@@ -197,7 +197,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
@@ -225,22 +225,22 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
|
||||
},
|
||||
"categories": {
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"visual-engineering": {
|
||||
@@ -264,7 +264,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"model": "opencode/gpt-5-nano",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
@@ -292,14 +292,14 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
},
|
||||
"categories": {
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -307,7 +307,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-low": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"visual-engineering": {
|
||||
@@ -451,14 +451,14 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -473,11 +473,11 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -487,14 +487,14 @@ exports[`generateModelConfig all native providers uses preferred models from fal
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -524,14 +524,14 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -546,11 +546,11 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -560,18 +560,18 @@ exports[`generateModelConfig all native providers uses preferred models with isM
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-low": {
|
||||
@@ -598,14 +598,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -620,11 +620,11 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -634,14 +634,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -671,14 +671,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -693,11 +693,11 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -707,18 +707,18 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "opencode/claude-opus-4-5",
|
||||
"model": "opencode/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-low": {
|
||||
@@ -745,14 +745,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -767,11 +767,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -781,14 +781,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -818,14 +818,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -840,11 +840,11 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -854,18 +854,18 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-low": {
|
||||
@@ -1002,14 +1002,14 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "opencode/glm-4.7-free",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -1024,11 +1024,11 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1038,14 +1038,14 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "opencode/gpt-5.2-codex",
|
||||
"model": "opencode/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1075,14 +1075,14 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"model": "github-copilot/gpt-5-mini",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "github-copilot/claude-sonnet-4.5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -1097,11 +1097,11 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1111,14 +1111,14 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1151,26 +1151,26 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "zai-coding-plan/glm-4.6v",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1179,7 +1179,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1189,7 +1189,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"visual-engineering": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"writing": {
|
||||
@@ -1213,11 +1213,11 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
@@ -1228,11 +1228,11 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1275,14 +1275,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"model": "opencode/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -1297,11 +1297,11 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "github-copilot/claude-opus-4.5",
|
||||
"model": "github-copilot/claude-opus-4.6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1311,14 +1311,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "github-copilot/claude-haiku-4.5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "github-copilot/gpt-5.2-codex",
|
||||
"model": "github-copilot/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1348,14 +1348,14 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -1370,11 +1370,11 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1384,14 +1384,14 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
@@ -1421,14 +1421,14 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"hephaestus": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"librarian": {
|
||||
"model": "zai-coding-plan/glm-4.7",
|
||||
},
|
||||
"metis": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"momus": {
|
||||
@@ -1443,11 +1443,11 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"sisyphus": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
},
|
||||
@@ -1457,18 +1457,18 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
|
||||
"variant": "high",
|
||||
},
|
||||
"deep": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "medium",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "openai/gpt-5.2-codex",
|
||||
"model": "openai/gpt-5.3-codex",
|
||||
"variant": "xhigh",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-opus-4-5",
|
||||
"model": "anthropic/claude-opus-4-6",
|
||||
"variant": "max",
|
||||
},
|
||||
"unspecified-low": {
|
||||
|
||||
@@ -259,7 +259,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #then Sisyphus uses Claude (OR logic - at least one provider available)
|
||||
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
|
||||
expect(result.agents).toBeDefined()
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("generates native opus models when Claude max20 subscription", () => {
|
||||
@@ -279,7 +279,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then Sisyphus uses Claude (OR logic - at least one provider available)
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("uses github-copilot sonnet fallback when only copilot available", () => {
|
||||
@@ -298,8 +298,8 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then Sisyphus uses Copilot (OR logic - copilot is in claude-opus-4-5 providers)
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-opus-4.5")
|
||||
// #then Sisyphus uses Copilot (OR logic - copilot is in claude-opus-4-6 providers)
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-opus-4.6")
|
||||
})
|
||||
|
||||
test("uses ultimate fallback when no providers configured", () => {
|
||||
@@ -342,7 +342,7 @@ describe("generateOmoConfig - model fallback system", () => {
|
||||
// #then librarian should use zai-coding-plan/glm-4.7
|
||||
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
|
||||
// #then Sisyphus uses Claude (OR logic)
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("uses native OpenAI models when only ChatGPT available", () => {
|
||||
|
||||
@@ -14,9 +14,8 @@ describe("model-resolution check", () => {
|
||||
// then: Should have agent entries
|
||||
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-5")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
|
||||
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("github-copilot")
|
||||
})
|
||||
|
||||
it("returns category requirements with provider chains", async () => {
|
||||
@@ -43,7 +42,7 @@ describe("model-resolution check", () => {
|
||||
// given: User has override for oracle agent
|
||||
const mockConfig = {
|
||||
agents: {
|
||||
oracle: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -52,8 +51,8 @@ describe("model-resolution check", () => {
|
||||
// then: Oracle should show the override
|
||||
const oracle = info.agents.find((a) => a.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-5")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-5")
|
||||
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-6")
|
||||
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
it("shows user override for category when configured", async () => {
|
||||
@@ -90,6 +89,46 @@ describe("model-resolution check", () => {
|
||||
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
|
||||
expect(sisyphus!.effectiveResolution).toContain("anthropic")
|
||||
})
|
||||
|
||||
it("captures user variant for agent when configured", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
//#given User has model with variant override for oracle agent
|
||||
const mockConfig = {
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.2", variant: "xhigh" },
|
||||
},
|
||||
}
|
||||
|
||||
//#when getting resolution info with config
|
||||
const info = getModelResolutionInfoWithOverrides(mockConfig)
|
||||
|
||||
//#then Oracle should have userVariant set
|
||||
const oracle = info.agents.find((a) => a.name === "oracle")
|
||||
expect(oracle).toBeDefined()
|
||||
expect(oracle!.userOverride).toBe("openai/gpt-5.2")
|
||||
expect(oracle!.userVariant).toBe("xhigh")
|
||||
})
|
||||
|
||||
it("captures user variant for category when configured", async () => {
|
||||
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
|
||||
|
||||
//#given User has model with variant override for visual-engineering category
|
||||
const mockConfig = {
|
||||
categories: {
|
||||
"visual-engineering": { model: "google/gemini-3-flash-preview", variant: "high" },
|
||||
},
|
||||
}
|
||||
|
||||
//#when getting resolution info with config
|
||||
const info = getModelResolutionInfoWithOverrides(mockConfig)
|
||||
|
||||
//#then visual-engineering should have userVariant set
|
||||
const visual = info.categories.find((c) => c.name === "visual-engineering")
|
||||
expect(visual).toBeDefined()
|
||||
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
|
||||
expect(visual!.userVariant).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkModelResolution", () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface AgentResolutionInfo {
|
||||
name: string
|
||||
requirement: ModelRequirement
|
||||
userOverride?: string
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
}
|
||||
@@ -59,6 +60,7 @@ export interface CategoryResolutionInfo {
|
||||
name: string
|
||||
requirement: ModelRequirement
|
||||
userOverride?: string
|
||||
userVariant?: string
|
||||
effectiveModel: string
|
||||
effectiveResolution: string
|
||||
}
|
||||
@@ -152,10 +154,12 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
|
||||
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
const userOverride = config.agents?.[name]?.model
|
||||
const userVariant = config.agents?.[name]?.variant
|
||||
return {
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
@@ -165,10 +169,12 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
|
||||
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
|
||||
([name, requirement]) => {
|
||||
const userOverride = config.categories?.[name]?.model
|
||||
const userVariant = config.categories?.[name]?.variant
|
||||
return {
|
||||
name,
|
||||
requirement,
|
||||
userOverride,
|
||||
userVariant,
|
||||
effectiveModel: getEffectiveModel(requirement, userOverride),
|
||||
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
|
||||
OpenAI Native openai/ models (GPT-5.2 for Oracle)
|
||||
Gemini Native google/ models (Gemini 3 Pro, Flash)
|
||||
Copilot github-copilot/ models (fallback)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-5, etc.)
|
||||
OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
|
||||
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
|
||||
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
|
||||
`)
|
||||
|
||||
@@ -243,7 +243,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
message: "Do you have access to OpenCode Zen (opencode/ models)?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
|
||||
{ value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-5, opencode/gpt-5.2, etc." },
|
||||
{ value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
|
||||
],
|
||||
initialValue: initial.opencodeZen,
|
||||
})
|
||||
|
||||
@@ -376,7 +376,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("Sisyphus is created when multiple fallback providers are available", () => {
|
||||
@@ -393,7 +393,7 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("Sisyphus is omitted when no fallback provider is available (OpenAI not in chain)", () => {
|
||||
@@ -409,7 +409,7 @@ describe("generateModelConfig", () => {
|
||||
})
|
||||
|
||||
describe("Hephaestus agent special cases", () => {
|
||||
test("Hephaestus is created when OpenAI is available (has gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is created when OpenAI is available (openai provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasOpenAI: true })
|
||||
|
||||
@@ -417,11 +417,11 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.hephaestus?.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(result.agents?.hephaestus?.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("Hephaestus is created when Copilot is available (has gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is created when Copilot is available (github-copilot provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasCopilot: true })
|
||||
|
||||
@@ -429,11 +429,11 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.2-codex")
|
||||
expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.3-codex")
|
||||
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("Hephaestus is created when OpenCode Zen is available (has gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasOpencodeZen: true })
|
||||
|
||||
@@ -441,11 +441,11 @@ describe("generateModelConfig", () => {
|
||||
const result = generateModelConfig(config)
|
||||
|
||||
// #then
|
||||
expect(result.agents?.hephaestus?.model).toBe("opencode/gpt-5.2-codex")
|
||||
expect(result.agents?.hephaestus?.model).toBe("opencode/gpt-5.3-codex")
|
||||
expect(result.agents?.hephaestus?.variant).toBe("medium")
|
||||
})
|
||||
|
||||
test("Hephaestus is omitted when only Claude is available (no gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is omitted when only Claude is available (no required provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasClaude: true })
|
||||
|
||||
@@ -456,7 +456,7 @@ describe("generateModelConfig", () => {
|
||||
expect(result.agents?.hephaestus).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Hephaestus is omitted when only Gemini is available (no gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is omitted when only Gemini is available (no required provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasGemini: true })
|
||||
|
||||
@@ -467,7 +467,7 @@ describe("generateModelConfig", () => {
|
||||
expect(result.agents?.hephaestus).toBeUndefined()
|
||||
})
|
||||
|
||||
test("Hephaestus is omitted when only ZAI is available (no gpt-5.2-codex)", () => {
|
||||
test("Hephaestus is omitted when only ZAI is available (no required provider connected)", () => {
|
||||
// #given
|
||||
const config = createConfig({ hasZaiCodingPlan: true })
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ function isProviderAvailable(provider: string, avail: ProviderAvailability): boo
|
||||
function transformModelForProvider(provider: string, model: string): string {
|
||||
if (provider === "github-copilot") {
|
||||
return model
|
||||
.replace("claude-opus-4-5", "claude-opus-4.5")
|
||||
.replace("claude-opus-4-6", "claude-opus-4.6")
|
||||
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
|
||||
.replace("claude-haiku-4-5", "claude-haiku-4.5")
|
||||
.replace("claude-sonnet-4", "claude-sonnet-4")
|
||||
@@ -122,6 +122,13 @@ function isRequiredModelAvailable(
|
||||
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail))
|
||||
}
|
||||
|
||||
function isRequiredProviderAvailable(
|
||||
requiredProviders: string[],
|
||||
avail: ProviderAvailability
|
||||
): boolean {
|
||||
return requiredProviders.some((provider) => isProviderAvailable(provider, avail))
|
||||
}
|
||||
|
||||
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
const avail = toProviderAvailability(config)
|
||||
const hasAnyProvider =
|
||||
@@ -185,6 +192,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) {
|
||||
continue
|
||||
}
|
||||
if (req.requiresProvider && !isRequiredProviderAvailable(req.requiresProvider, avail)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = resolveModelFromChain(req.fallbackChain, avail)
|
||||
if (resolved) {
|
||||
@@ -205,6 +215,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
|
||||
if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) {
|
||||
continue
|
||||
}
|
||||
if (req.requiresProvider && !isRequiredProviderAvailable(req.requiresProvider, avail)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const resolved = resolveModelFromChain(fallbackChain, avail)
|
||||
if (resolved) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { checkCompletionConditions } from "./completion"
|
||||
import { createEventState, processEvents, serializeError } from "./events"
|
||||
import type { OhMyOpenCodeConfig } from "../../config"
|
||||
import { loadPluginConfig } from "../../plugin-config"
|
||||
import { getAvailableServerPort, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
|
||||
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const DEFAULT_TIMEOUT_MS = 0
|
||||
@@ -89,7 +90,7 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
const pluginConfig = loadPluginConfig(directory, { command: "run" })
|
||||
const resolvedAgent = resolveRunAgent(options, pluginConfig)
|
||||
|
||||
console.log(pc.cyan("Starting opencode server..."))
|
||||
console.log(pc.cyan("Starting opencode server (auto port selection enabled)..."))
|
||||
|
||||
const abortController = new AbortController()
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -103,18 +104,24 @@ export async function run(options: RunOptions): Promise<number> {
|
||||
}
|
||||
|
||||
try {
|
||||
// Support custom OpenCode server port via environment variable
|
||||
// This allows Open Agent and other orchestrators to run multiple
|
||||
// concurrent missions without port conflicts
|
||||
const serverPort = process.env.OPENCODE_SERVER_PORT
|
||||
const envPort = process.env.OPENCODE_SERVER_PORT
|
||||
? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
|
||||
: undefined
|
||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined
|
||||
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || "127.0.0.1"
|
||||
const preferredPort = envPort && !isNaN(envPort) ? envPort : DEFAULT_SERVER_PORT
|
||||
|
||||
const { port: serverPort, wasAutoSelected } = await getAvailableServerPort(preferredPort, serverHostname)
|
||||
|
||||
if (wasAutoSelected) {
|
||||
console.log(pc.yellow(`Port ${preferredPort} is busy, using port ${serverPort} instead`))
|
||||
} else {
|
||||
console.log(pc.dim(`Using port ${serverPort}`))
|
||||
}
|
||||
|
||||
const { client, server } = await createOpencode({
|
||||
signal: abortController.signal,
|
||||
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}),
|
||||
...(serverHostname ? { hostname: serverHostname } : {}),
|
||||
port: serverPort,
|
||||
hostname: serverHostname,
|
||||
})
|
||||
|
||||
const cleanup = () => {
|
||||
|
||||
@@ -32,6 +32,7 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
"playwright",
|
||||
"agent-browser",
|
||||
"dev-browser",
|
||||
"frontend-ui-ux",
|
||||
"git-master",
|
||||
])
|
||||
@@ -63,10 +64,12 @@ export const HookNameSchema = z.enum([
|
||||
"comment-checker",
|
||||
"grep-output-truncator",
|
||||
"tool-output-truncator",
|
||||
"question-label-truncator",
|
||||
"directory-agents-injector",
|
||||
"directory-readme-injector",
|
||||
"empty-task-response-detector",
|
||||
"think-mode",
|
||||
"subagent-question-blocker",
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"preemptive-compaction",
|
||||
"rules-injector",
|
||||
@@ -92,13 +95,21 @@ export const HookNameSchema = z.enum([
|
||||
"start-work",
|
||||
"atlas",
|
||||
"unstable-agent-babysitter",
|
||||
"task-reminder",
|
||||
"task-resume-info",
|
||||
"stop-continuation-guard",
|
||||
"tasks-todowrite-disabler",
|
||||
"write-existing-file-guard",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
"init-deep",
|
||||
"ralph-loop",
|
||||
"ulw-loop",
|
||||
"cancel-ralph",
|
||||
"refactor",
|
||||
"start-work",
|
||||
"stop-continuation",
|
||||
])
|
||||
|
||||
export const AgentOverrideConfigSchema = z.object({
|
||||
@@ -368,8 +379,10 @@ export const TmuxConfigSchema = z.object({
|
||||
})
|
||||
|
||||
export const SisyphusTasksConfigSchema = z.object({
|
||||
/** Storage path for tasks (default: .sisyphus/tasks) */
|
||||
storage_path: z.string().default(".sisyphus/tasks"),
|
||||
/** Absolute or relative storage path override. When set, bypasses global config dir. */
|
||||
storage_path: z.string().optional(),
|
||||
/** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */
|
||||
task_list_id: z.string().optional(),
|
||||
/** Enable Claude Code path compatibility mode */
|
||||
claude_code_compat: z.boolean().default(false),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
20 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer.
|
||||
17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management.
|
||||
|
||||
**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities
|
||||
|
||||
@@ -10,27 +10,25 @@
|
||||
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Task lifecycle (1418 lines)
|
||||
├── background-agent/ # Task lifecycle (1556 lines)
|
||||
│ ├── manager.ts # Launch → poll → complete
|
||||
│ └── concurrency.ts # Per-provider limits
|
||||
├── builtin-skills/ # Core skills (1729 lines)
|
||||
│ └── skills.ts # playwright, dev-browser, frontend-ui-ux, git-master, typescript-programmer
|
||||
├── builtin-skills/ # Core skills
|
||||
│ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser
|
||||
├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # installed_plugins.json
|
||||
├── claude-code-plugin-loader/ # installed_plugins.json (486 lines)
|
||||
├── claude-code-session-state/ # Session persistence
|
||||
├── opencode-skill-loader/ # Skills from 6 directories
|
||||
├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines)
|
||||
├── context-injector/ # AGENTS.md/README.md injection
|
||||
├── boulder-state/ # Todo state persistence
|
||||
├── hook-message-injector/ # Message injection
|
||||
├── task-toast-manager/ # Background task notifications
|
||||
├── skill-mcp-manager/ # MCP client lifecycle (617 lines)
|
||||
├── tmux-subagent/ # Tmux session management
|
||||
├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
|
||||
├── tmux-subagent/ # Tmux session management (472 lines)
|
||||
├── mcp-oauth/ # MCP OAuth handling
|
||||
├── sisyphus-swarm/ # Swarm coordination
|
||||
├── sisyphus-tasks/ # Task tracking
|
||||
└── claude-tasks/ # Task schema/storage - see AGENTS.md
|
||||
```
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
|
||||
// when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// then
|
||||
|
||||
@@ -783,7 +783,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
}
|
||||
const currentMessage: CurrentMessage = {
|
||||
agent: "sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
|
||||
// when
|
||||
@@ -791,7 +791,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
|
||||
// then - uses currentMessage values, not task.parentModel/parentAgent
|
||||
expect(promptBody.agent).toBe("sisyphus")
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-5" })
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
|
||||
})
|
||||
|
||||
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
|
||||
@@ -875,6 +875,90 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
|
||||
test("should skip notification when parent session is aborted", async () => {
|
||||
//#given
|
||||
let promptCalled = false
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => {
|
||||
promptCalled = true
|
||||
return {}
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => {
|
||||
const error = new Error("User aborted")
|
||||
error.name = "MessageAbortedError"
|
||||
throw error
|
||||
},
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-parent",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task aborted parent",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id, "task-remaining"]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalled).toBe(false)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
|
||||
test("should swallow aborted error from prompt", async () => {
|
||||
//#given
|
||||
let promptCalled = false
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async () => {
|
||||
promptCalled = true
|
||||
const error = new Error("User aborted")
|
||||
error.name = "MessageAbortedError"
|
||||
throw error
|
||||
},
|
||||
abort: async () => ({}),
|
||||
messages: async () => ({ data: [] }),
|
||||
},
|
||||
}
|
||||
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-aborted-prompt",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task aborted prompt",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
}
|
||||
getPendingByParent(manager).set("session-parent", new Set([task.id]))
|
||||
|
||||
//#when
|
||||
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
|
||||
.notifyParentSession(task)
|
||||
|
||||
//#then
|
||||
expect(promptCalled).toBe(true)
|
||||
|
||||
manager.shutdown()
|
||||
})
|
||||
})
|
||||
|
||||
function buildNotificationPromptBody(
|
||||
task: BackgroundTask,
|
||||
currentMessage: CurrentMessage | null
|
||||
@@ -913,7 +997,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should release concurrency and clear key on completion", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4-5"
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
@@ -942,7 +1026,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
|
||||
|
||||
test("should prevent double completion and double release", async () => {
|
||||
// given
|
||||
const concurrencyKey = "anthropic/claude-opus-4-5"
|
||||
const concurrencyKey = "anthropic/claude-opus-4-6"
|
||||
const concurrencyManager = getConcurrencyManager(manager)
|
||||
await concurrencyManager.acquire(concurrencyKey)
|
||||
|
||||
@@ -1573,7 +1657,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
|
||||
description: "Task 1",
|
||||
prompt: "Do something",
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
parentSessionID: "parent-session",
|
||||
parentMessageID: "parent-message",
|
||||
}
|
||||
|
||||
@@ -1123,7 +1123,14 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (this.isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
@@ -1154,6 +1161,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
if (this.isAbortedSessionError(error)) {
|
||||
log("[background-agent] Parent session aborted, skipping notification:", {
|
||||
taskId: task.id,
|
||||
parentSessionID: task.parentSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
@@ -1192,6 +1206,28 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
private isAbortedSessionError(error: unknown): boolean {
|
||||
const message = this.getErrorText(error)
|
||||
return message.toLowerCase().includes("aborted")
|
||||
}
|
||||
|
||||
private getErrorText(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}: ${error.message}`
|
||||
}
|
||||
if (typeof error === "object" && error !== null) {
|
||||
if ("message" in error && typeof error.message === "string") {
|
||||
return error.message
|
||||
}
|
||||
if ("name" in error && typeof error.name === "string") {
|
||||
return error.name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private hasRunningTasks(): boolean {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status === "running") return true
|
||||
|
||||
@@ -246,5 +246,33 @@ describe("boulder-state", () => {
|
||||
expect(state.plan_name).toBe("auth-refactor")
|
||||
expect(state.started_at).toBeDefined()
|
||||
})
|
||||
|
||||
test("should include agent field when provided", () => {
|
||||
//#given - plan path, session id, and agent type
|
||||
const planPath = "/path/to/feature.md"
|
||||
const sessionId = "ses-xyz789"
|
||||
const agent = "atlas"
|
||||
|
||||
//#when - createBoulderState is called with agent
|
||||
const state = createBoulderState(planPath, sessionId, agent)
|
||||
|
||||
//#then - state should include the agent field
|
||||
expect(state.agent).toBe("atlas")
|
||||
expect(state.active_plan).toBe(planPath)
|
||||
expect(state.session_ids).toEqual([sessionId])
|
||||
expect(state.plan_name).toBe("feature")
|
||||
})
|
||||
|
||||
test("should allow agent to be undefined", () => {
|
||||
//#given - plan path and session id without agent
|
||||
const planPath = "/path/to/legacy.md"
|
||||
const sessionId = "ses-legacy"
|
||||
|
||||
//#when - createBoulderState is called without agent
|
||||
const state = createBoulderState(planPath, sessionId)
|
||||
|
||||
//#then - state should not have agent field (backward compatible)
|
||||
expect(state.agent).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -139,12 +139,14 @@ export function getPlanName(planPath: string): string {
|
||||
*/
|
||||
export function createBoulderState(
|
||||
planPath: string,
|
||||
sessionId: string
|
||||
sessionId: string,
|
||||
agent?: string
|
||||
): BoulderState {
|
||||
return {
|
||||
active_plan: planPath,
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: [sessionId],
|
||||
plan_name: getPlanName(planPath),
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface BoulderState {
|
||||
session_ids: string[]
|
||||
/** Plan name derived from filename */
|
||||
plan_name: string
|
||||
/** Agent type to use when resuming (e.g., 'atlas') */
|
||||
agent?: string
|
||||
}
|
||||
|
||||
export interface PlanProgress {
|
||||
|
||||
@@ -12,6 +12,7 @@ claude-tasks/
|
||||
├── types.test.ts # Schema validation tests (8 tests)
|
||||
├── storage.ts # File operations
|
||||
├── storage.test.ts # Storage tests (14 tests)
|
||||
├── todo-sync.ts # Task → Todo synchronization
|
||||
└── index.ts # Barrel exports
|
||||
```
|
||||
|
||||
@@ -44,67 +45,21 @@ interface Task {
|
||||
|
||||
## TODO SYNC
|
||||
|
||||
The task system includes a sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system.
|
||||
Task system includes sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system.
|
||||
|
||||
- **Creation**: Creating a task via `task_create` adds a corresponding item to the Todo list.
|
||||
- **Updates**: Updating a task's `status` or `subject` via `task_update` reflects in the Todo list.
|
||||
- **Completion**: Marking a task as `completed` automatically marks the Todo item as done.
|
||||
- **Creation**: `task_create` adds corresponding Todo item
|
||||
- **Updates**: `task_update` reflects in Todo list
|
||||
- **Completion**: `completed` status marks Todo item done
|
||||
|
||||
## STORAGE UTILITIES
|
||||
|
||||
### getTaskDir(config)
|
||||
|
||||
Returns: `.sisyphus/tasks` (or custom path from config)
|
||||
|
||||
### readJsonSafe(filePath, schema)
|
||||
|
||||
- Returns parsed & validated data or `null`
|
||||
- Safe for missing files, invalid JSON, schema violations
|
||||
|
||||
### writeJsonAtomic(filePath, data)
|
||||
|
||||
- Atomic write via temp file + rename
|
||||
- Creates parent directories automatically
|
||||
- Cleans up temp file on error
|
||||
|
||||
### acquireLock(dirPath)
|
||||
|
||||
- File-based lock: `.lock` file with timestamp
|
||||
- 30-second stale threshold
|
||||
- Returns `{ acquired: boolean, release: () => void }`
|
||||
|
||||
## TESTING
|
||||
|
||||
**types.test.ts** (8 tests):
|
||||
- Valid status enum values
|
||||
- Required vs optional fields
|
||||
- Array validation (blocks, blockedBy)
|
||||
- Schema rejection for invalid data
|
||||
|
||||
**storage.test.ts** (14 tests):
|
||||
- Path construction
|
||||
- Safe JSON reading (missing files, invalid JSON, schema failures)
|
||||
- Atomic writes (directory creation, overwrites)
|
||||
- Lock acquisition (fresh locks, stale locks, release)
|
||||
|
||||
## USAGE
|
||||
|
||||
```typescript
|
||||
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
|
||||
|
||||
const taskDir = getTaskDir(config)
|
||||
const lock = acquireLock(taskDir)
|
||||
|
||||
try {
|
||||
const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema)
|
||||
if (task) {
|
||||
task.status = "completed"
|
||||
writeJsonAtomic(join(taskDir, "1.json"), task)
|
||||
}
|
||||
} finally {
|
||||
lock.release()
|
||||
}
|
||||
```
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `getTaskDir(config)` | Returns task storage directory path |
|
||||
| `resolveTaskListId(config)` | Resolves task list ID (env → config → cwd basename) |
|
||||
| `readJsonSafe(path, schema)` | Parse + validate, returns null on failure |
|
||||
| `writeJsonAtomic(path, data)` | Atomic write via temp file + rename |
|
||||
| `acquireLock(dirPath)` | File-based lock with 30s stale threshold |
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
|
||||
@@ -1,26 +1,99 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { join, basename } from "path"
|
||||
import { z } from "zod"
|
||||
import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import {
|
||||
getTaskDir,
|
||||
readJsonSafe,
|
||||
writeJsonAtomic,
|
||||
acquireLock,
|
||||
generateTaskId,
|
||||
listTaskFiles,
|
||||
resolveTaskListId,
|
||||
sanitizePathSegment,
|
||||
} from "./storage"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
const TEST_DIR = ".test-claude-tasks"
|
||||
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
|
||||
|
||||
describe("getTaskDir", () => {
|
||||
test("returns correct path for default config", () => {
|
||||
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
|
||||
|
||||
beforeEach(() => {
|
||||
if (originalTaskListId === undefined) {
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
} else {
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTaskListId === undefined) {
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
} else {
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
|
||||
}
|
||||
})
|
||||
|
||||
test("returns global config path for default config", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {}
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const expectedListId = sanitizePathSegment(basename(process.cwd()))
|
||||
|
||||
//#when
|
||||
const result = getTaskDir(config)
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
||||
expect(result).toBe(join(configDir, "tasks", expectedListId))
|
||||
})
|
||||
|
||||
test("returns correct path with custom storage_path", () => {
|
||||
test("respects ULTRAWORK_TASK_LIST_ID env var", () => {
|
||||
//#given
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
|
||||
//#when
|
||||
const result = getTaskDir()
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(configDir, "tasks", "custom-list-id"))
|
||||
})
|
||||
|
||||
test("falls back to sanitized cwd basename when env var not set", () => {
|
||||
//#given
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const expectedListId = sanitizePathSegment(basename(process.cwd()))
|
||||
|
||||
//#when
|
||||
const result = getTaskDir()
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(configDir, "tasks", expectedListId))
|
||||
})
|
||||
|
||||
test("returns absolute storage_path without joining cwd", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
sisyphus: {
|
||||
tasks: {
|
||||
storage_path: "/tmp/custom-task-path",
|
||||
claude_code_compat: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = getTaskDir(config)
|
||||
|
||||
//#then
|
||||
expect(result).toBe("/tmp/custom-task-path")
|
||||
})
|
||||
|
||||
test("joins relative storage_path with cwd", () => {
|
||||
//#given
|
||||
const config: Partial<OhMyOpenCodeConfig> = {
|
||||
sisyphus: {
|
||||
@@ -37,13 +110,59 @@ describe("getTaskDir", () => {
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".custom/tasks"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveTaskListId", () => {
|
||||
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
|
||||
|
||||
beforeEach(() => {
|
||||
if (originalTaskListId === undefined) {
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
} else {
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalTaskListId === undefined) {
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
} else {
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
|
||||
}
|
||||
})
|
||||
|
||||
test("returns env var when set", () => {
|
||||
//#given
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = "custom-list"
|
||||
|
||||
test("returns correct path with default config parameter", () => {
|
||||
//#when
|
||||
const result = getTaskDir()
|
||||
const result = resolveTaskListId()
|
||||
|
||||
//#then
|
||||
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks"))
|
||||
expect(result).toBe("custom-list")
|
||||
})
|
||||
|
||||
test("sanitizes special characters", () => {
|
||||
//#given
|
||||
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"
|
||||
|
||||
//#when
|
||||
const result = resolveTaskListId()
|
||||
|
||||
//#then
|
||||
expect(result).toBe("custom-list-id")
|
||||
})
|
||||
|
||||
test("returns sanitized cwd basename when env var not set", () => {
|
||||
//#given
|
||||
delete process.env.ULTRAWORK_TASK_LIST_ID
|
||||
const expected = sanitizePathSegment(basename(process.cwd()))
|
||||
|
||||
//#when
|
||||
const result = resolveTaskListId()
|
||||
|
||||
//#then
|
||||
expect(result).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import { join, dirname } from "path"
|
||||
import { join, dirname, basename, isAbsolute } from "path"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs"
|
||||
import { randomUUID } from "crypto"
|
||||
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
|
||||
import type { z } from "zod"
|
||||
import type { OhMyOpenCodeConfig } from "../../config/schema"
|
||||
|
||||
export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string {
|
||||
const tasksConfig = config.sisyphus?.tasks
|
||||
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
|
||||
return join(process.cwd(), storagePath)
|
||||
const storagePath = tasksConfig?.storage_path
|
||||
|
||||
if (storagePath) {
|
||||
return isAbsolute(storagePath) ? storagePath : join(process.cwd(), storagePath)
|
||||
}
|
||||
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const listId = resolveTaskListId(config)
|
||||
return join(configDir, "tasks", listId)
|
||||
}
|
||||
|
||||
export function sanitizePathSegment(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, "-") || "default"
|
||||
}
|
||||
|
||||
export function resolveTaskListId(config: Partial<OhMyOpenCodeConfig> = {}): string {
|
||||
const envId = process.env.ULTRAWORK_TASK_LIST_ID?.trim()
|
||||
if (envId) return sanitizePathSegment(envId)
|
||||
|
||||
const configId = config.sisyphus?.tasks?.task_list_id?.trim()
|
||||
if (configId) return sanitizePathSegment(configId)
|
||||
|
||||
return sanitizePathSegment(basename(process.cwd()))
|
||||
}
|
||||
|
||||
export function ensureDir(dirPath: string): void {
|
||||
|
||||
@@ -388,169 +388,175 @@ Skill body.
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested skill discovery", () => {
|
||||
it("discovers skills in nested directories (superpowers pattern)", async () => {
|
||||
// #given - simulate superpowers structure: skills/superpowers/brainstorming/SKILL.md
|
||||
const nestedDir = join(SKILLS_DIR, "superpowers", "brainstorming")
|
||||
mkdirSync(nestedDir, { recursive: true })
|
||||
const skillContent = `---
|
||||
name: brainstorming
|
||||
description: A nested skill for brainstorming
|
||||
describe("deduplication", () => {
|
||||
it("deduplicates skills by name across scopes, keeping higher priority (opencode-project > opencode > project)", async () => {
|
||||
const originalCwd = process.cwd()
|
||||
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
// given: same skill name in multiple scopes
|
||||
const opencodeProjectSkillsDir = join(TEST_DIR, ".opencode", "skills")
|
||||
const opencodeConfigDir = join(TEST_DIR, "opencode-global")
|
||||
const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills")
|
||||
const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills")
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user")
|
||||
|
||||
mkdirSync(join(opencodeProjectSkillsDir, "duplicate-skill"), { recursive: true })
|
||||
mkdirSync(join(opencodeGlobalSkillsDir, "duplicate-skill"), { recursive: true })
|
||||
mkdirSync(join(projectClaudeSkillsDir, "duplicate-skill"), { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(opencodeProjectSkillsDir, "duplicate-skill", "SKILL.md"),
|
||||
`---
|
||||
name: duplicate-skill
|
||||
description: From opencode-project (highest priority)
|
||||
---
|
||||
This is a nested skill.
|
||||
opencode-project body.
|
||||
`
|
||||
writeFileSync(join(nestedDir, "SKILL.md"), skillContent)
|
||||
)
|
||||
|
||||
// #when
|
||||
writeFileSync(
|
||||
join(opencodeGlobalSkillsDir, "duplicate-skill", "SKILL.md"),
|
||||
`---
|
||||
name: duplicate-skill
|
||||
description: From opencode-global (middle priority)
|
||||
---
|
||||
opencode-global body.
|
||||
`
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
join(projectClaudeSkillsDir, "duplicate-skill", "SKILL.md"),
|
||||
`---
|
||||
name: duplicate-skill
|
||||
description: From claude project (lowest priority among these)
|
||||
---
|
||||
claude project body.
|
||||
`
|
||||
)
|
||||
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "superpowers/brainstorming")
|
||||
const skills = await discoverSkills()
|
||||
const duplicates = skills.filter(s => s.name === "duplicate-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.name).toBe("superpowers/brainstorming")
|
||||
expect(skill?.definition.description).toContain("brainstorming")
|
||||
// then
|
||||
expect(duplicates).toHaveLength(1)
|
||||
expect(duplicates[0]?.scope).toBe("opencode-project")
|
||||
expect(duplicates[0]?.definition.description).toContain("opencode-project")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("discovers multiple skills in nested directories", async () => {
|
||||
// #given - multiple nested skills
|
||||
const skills = ["brainstorming", "debugging", "testing"]
|
||||
for (const skillName of skills) {
|
||||
const nestedDir = join(SKILLS_DIR, "superpowers", skillName)
|
||||
mkdirSync(nestedDir, { recursive: true })
|
||||
writeFileSync(join(nestedDir, "SKILL.md"), `---
|
||||
name: ${skillName}
|
||||
description: ${skillName} skill
|
||||
---
|
||||
Content for ${skillName}.
|
||||
`)
|
||||
}
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const discoveredSkills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
|
||||
// #then
|
||||
for (const skillName of skills) {
|
||||
const skill = discoveredSkills.find(s => s.name === `superpowers/${skillName}`)
|
||||
expect(skill).toBeDefined()
|
||||
if (originalOpenCodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
|
||||
}
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||
}
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("respects max depth limit", async () => {
|
||||
// #given - deeply nested skill (3 levels deep, beyond default maxDepth of 2)
|
||||
const deepDir = join(SKILLS_DIR, "level1", "level2", "level3", "deep-skill")
|
||||
mkdirSync(deepDir, { recursive: true })
|
||||
writeFileSync(join(deepDir, "SKILL.md"), `---
|
||||
name: deep-skill
|
||||
description: A deeply nested skill
|
||||
---
|
||||
Too deep.
|
||||
`)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
it("prioritizes OpenCode global skills over legacy Claude project skills", async () => {
|
||||
const originalCwd = process.cwd()
|
||||
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
const opencodeConfigDir = join(TEST_DIR, "opencode-global")
|
||||
const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills")
|
||||
const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills")
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
|
||||
process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user")
|
||||
|
||||
mkdirSync(join(opencodeGlobalSkillsDir, "global-over-project"), { recursive: true })
|
||||
mkdirSync(join(projectClaudeSkillsDir, "global-over-project"), { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(opencodeGlobalSkillsDir, "global-over-project", "SKILL.md"),
|
||||
`---
|
||||
name: global-over-project
|
||||
description: From opencode-global (should win)
|
||||
---
|
||||
opencode-global body.
|
||||
`
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
join(projectClaudeSkillsDir, "global-over-project", "SKILL.md"),
|
||||
`---
|
||||
name: global-over-project
|
||||
description: From claude project (should lose)
|
||||
---
|
||||
claude project body.
|
||||
`
|
||||
)
|
||||
|
||||
const { discoverSkills } = await import("./loader")
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name.includes("deep-skill"))
|
||||
const skills = await discoverSkills()
|
||||
const matches = skills.filter(s => s.name === "global-over-project")
|
||||
|
||||
// #then - should not find skill beyond maxDepth
|
||||
expect(skill).toBeUndefined()
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]?.scope).toBe("opencode")
|
||||
expect(matches[0]?.definition.description).toContain("opencode-global")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
if (originalOpenCodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
|
||||
}
|
||||
if (originalClaudeConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("flat skills still work alongside nested skills", async () => {
|
||||
// #given - both flat and nested skills
|
||||
const flatSkillDir = join(SKILLS_DIR, "flat-skill")
|
||||
mkdirSync(flatSkillDir, { recursive: true })
|
||||
writeFileSync(join(flatSkillDir, "SKILL.md"), `---
|
||||
name: flat-skill
|
||||
description: A flat skill
|
||||
---
|
||||
Flat content.
|
||||
`)
|
||||
|
||||
const nestedDir = join(SKILLS_DIR, "nested", "nested-skill")
|
||||
mkdirSync(nestedDir, { recursive: true })
|
||||
writeFileSync(join(nestedDir, "SKILL.md"), `---
|
||||
name: nested-skill
|
||||
description: A nested skill
|
||||
---
|
||||
Nested content.
|
||||
`)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
it("returns no duplicates from discoverSkills", async () => {
|
||||
const originalCwd = process.cwd()
|
||||
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
process.env.OPENCODE_CONFIG_DIR = join(TEST_DIR, "opencode-global")
|
||||
|
||||
// given
|
||||
const skillContent = `---
|
||||
name: unique-test-skill
|
||||
description: A unique skill for dedup test
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("unique-test-skill", skillContent)
|
||||
|
||||
// when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
|
||||
// #then - both should be found
|
||||
const flatSkill = skills.find(s => s.name === "flat-skill")
|
||||
const nestedSkill = skills.find(s => s.name === "nested/nested-skill")
|
||||
|
||||
expect(flatSkill).toBeDefined()
|
||||
expect(nestedSkill).toBeDefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("prefers directory skill (SKILL.md) over file skill (*.md) on name collision", async () => {
|
||||
// #given - both foo.md file AND foo/SKILL.md directory exist
|
||||
// Directory skill should win (deterministic precedence: SKILL.md > {dir}.md > *.md)
|
||||
const dirSkillDir = join(SKILLS_DIR, "collision-test")
|
||||
mkdirSync(dirSkillDir, { recursive: true })
|
||||
writeFileSync(join(dirSkillDir, "SKILL.md"), `---
|
||||
name: collision-test
|
||||
description: Directory-based skill (should win)
|
||||
---
|
||||
I am the directory skill.
|
||||
`)
|
||||
|
||||
// Also create a file with same base name at parent level
|
||||
writeFileSync(join(SKILLS_DIR, "collision-test.md"), `---
|
||||
name: collision-test
|
||||
description: File-based skill (should lose)
|
||||
---
|
||||
I am the file skill.
|
||||
`)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
|
||||
// #then - only one skill should exist, and it should be the directory-based one
|
||||
const matchingSkills = skills.filter(s => s.name === "collision-test")
|
||||
expect(matchingSkills).toHaveLength(1)
|
||||
expect(matchingSkills[0]?.definition.description).toContain("Directory-based skill")
|
||||
// then
|
||||
const names = skills.map(s => s.name)
|
||||
const uniqueNames = [...new Set(names)]
|
||||
expect(names.length).toBe(uniqueNames.length)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
if (originalOpenCodeConfigDir === undefined) {
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -233,15 +233,33 @@ export interface DiscoverSkillsOptions {
|
||||
includeClaudeCodePaths?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates skills by name, keeping the first occurrence (higher priority).
|
||||
* Priority order: opencode-project > opencode > project > user
|
||||
* (OpenCode Global skills take precedence over legacy Claude project skills)
|
||||
*/
|
||||
function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] {
|
||||
const seen = new Set<string>()
|
||||
const result: LoadedSkill[] = []
|
||||
for (const skill of skills) {
|
||||
if (!seen.has(skill.name)) {
|
||||
seen.add(skill.name)
|
||||
result.push(skill)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
||||
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
|
||||
const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverProjectClaudeSkills(),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverProjectClaudeSkills(),
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
// Priority: opencode-project > opencode > project > user
|
||||
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
|
||||
}
|
||||
|
||||
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
@@ -253,7 +271,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
||||
])
|
||||
|
||||
if (!includeClaudeCodePaths) {
|
||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
||||
// Priority: opencode-project > opencode
|
||||
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills])
|
||||
}
|
||||
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
@@ -261,7 +280,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
// Priority: opencode-project > opencode > project > user
|
||||
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
|
||||
}
|
||||
|
||||
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||
|
||||
@@ -192,7 +192,7 @@ describe("TaskToastManager", () => {
|
||||
description: "Task with inherited model",
|
||||
agent: "sisyphus-junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const },
|
||||
modelInfo: { model: "cliproxy/claude-opus-4-6", type: "inherited" as const },
|
||||
}
|
||||
|
||||
// when - addTask is called
|
||||
@@ -202,7 +202,7 @@ describe("TaskToastManager", () => {
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("[FALLBACK]")
|
||||
expect(call.body.message).toContain("cliproxy/claude-opus-4-5")
|
||||
expect(call.body.message).toContain("cliproxy/claude-opus-4-6")
|
||||
expect(call.body.message).toContain("(inherited from parent)")
|
||||
})
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
34 lifecycle hooks intercepting/modifying agent behavior across 5 events.
|
||||
40+ lifecycle hooks intercepting/modifying agent behavior across 5 events.
|
||||
|
||||
**Event Types**:
|
||||
- `UserPromptSubmit` (`chat.message`) - Can block
|
||||
@@ -14,10 +14,10 @@
|
||||
## STRUCTURE
|
||||
```
|
||||
hooks/
|
||||
├── atlas/ # Main orchestration (757 lines)
|
||||
├── atlas/ # Main orchestration (770 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion (517 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (428 lines)
|
||||
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
|
||||
├── comment-checker/ # Prevents AI slop
|
||||
├── auto-slash-command/ # Detects /command patterns
|
||||
@@ -27,13 +27,14 @@ hooks/
|
||||
├── edit-error-recovery/ # Recovers from failures
|
||||
├── thinking-block-validator/ # Ensures valid <thinking>
|
||||
├── context-window-monitor.ts # Reminds of headroom
|
||||
├── session-recovery/ # Auto-recovers from crashes
|
||||
├── session-recovery/ # Auto-recovers from crashes (436 lines)
|
||||
├── session-notification.ts # Session event notifications (337 lines)
|
||||
├── 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
|
||||
├── auto-update-checker/ # Plugin update check (304 lines)
|
||||
├── tool-output-truncator.ts # Prevents context bloat
|
||||
├── compaction-context-injector/ # Injects context on compaction
|
||||
├── delegate-task-retry/ # Retries failed delegations
|
||||
@@ -47,6 +48,11 @@ hooks/
|
||||
├── sisyphus-junior-notepad/ # Sisyphus Junior notepad
|
||||
├── stop-continuation-guard/ # Guards stop continuation
|
||||
├── subagent-question-blocker/ # Blocks subagent questions
|
||||
├── task-reminder/ # Task progress reminders
|
||||
├── tasks-todowrite-disabler/ # Disables TodoWrite when task system active
|
||||
├── unstable-agent-babysitter/ # Monitors unstable agent behavior
|
||||
├── write-existing-file-guard/ # Guards against overwriting existing files
|
||||
├── preemptive-compaction.ts # Preemptive context compaction
|
||||
└── index.ts # Hook aggregation + registration
|
||||
```
|
||||
|
||||
@@ -61,8 +67,8 @@ hooks/
|
||||
|
||||
## EXECUTION ORDER
|
||||
- **UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork
|
||||
- **PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → atlasHook
|
||||
- **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo
|
||||
- **PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → atlasHook
|
||||
- **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder
|
||||
|
||||
## HOW TO ADD
|
||||
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
|
||||
|
||||
@@ -80,7 +80,7 @@ describe("executeCompact lock management", () => {
|
||||
let fakeTimeouts: FakeTimeouts
|
||||
const sessionID = "test-session-123"
|
||||
const directory = "/test/dir"
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const msg = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
beforeEach(() => {
|
||||
// given: Fresh state for each test
|
||||
@@ -332,7 +332,7 @@ describe("executeCompact lock management", () => {
|
||||
expect(mockClient.session.summarize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5", auto: true },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("atlas hook", () => {
|
||||
}
|
||||
const messageData = {
|
||||
agent,
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
|
||||
}
|
||||
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
|
||||
}
|
||||
@@ -624,6 +624,11 @@ describe("atlas hook", () => {
|
||||
describe("session.idle handler (boulder continuation)", () => {
|
||||
const MAIN_SESSION_ID = "main-session-123"
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mock.module("../../features/claude-code-session-state", () => ({
|
||||
getMainSessionID: () => MAIN_SESSION_ID,
|
||||
@@ -858,8 +863,8 @@ describe("atlas hook", () => {
|
||||
expect(callArgs.body.parts[0].text).toContain("2 remaining")
|
||||
})
|
||||
|
||||
test("should not inject when last agent is not Atlas", async () => {
|
||||
// given - boulder state with incomplete plan, but last agent is NOT Atlas
|
||||
test("should not inject when last agent does not match boulder agent", async () => {
|
||||
// given - boulder state with incomplete plan, but last agent does NOT match
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
@@ -868,10 +873,11 @@ describe("atlas hook", () => {
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "atlas",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
// given - last agent is NOT Atlas
|
||||
// given - last agent is NOT the boulder agent
|
||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
||||
|
||||
@@ -886,10 +892,44 @@ describe("atlas hook", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// then - should NOT call prompt because agent is not Atlas
|
||||
// then - should NOT call prompt because agent does not match
|
||||
expect(mockInput._promptMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
|
||||
// given - boulder state expects sisyphus and last agent is sisyphus
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "sisyphus",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
cleanupMessageStorage(MAIN_SESSION_ID)
|
||||
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
// when
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: MAIN_SESSION_ID },
|
||||
},
|
||||
})
|
||||
|
||||
// then - should call prompt for sisyphus
|
||||
expect(mockInput._promptMock).toHaveBeenCalled()
|
||||
const callArgs = mockInput._promptMock.mock.calls[0][0]
|
||||
expect(callArgs.body.agent).toBe("sisyphus")
|
||||
})
|
||||
|
||||
test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
|
||||
// given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
@@ -930,6 +970,135 @@ describe("atlas hook", () => {
|
||||
expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test("should stop continuation after 2 consecutive prompt failures (issue #1355)", async () => {
|
||||
//#given - boulder state with incomplete plan and prompt always fails
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - idle fires repeatedly, past cooldown each time
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - should attempt only twice, then disable continuation
|
||||
expect(promptMock).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should reset prompt failure counter on success and only stop after 2 consecutive failures", async () => {
|
||||
//#given - boulder state with incomplete plan
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock(() => Promise.resolve())
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.resolve())
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
|
||||
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - fail, succeed (reset), then fail twice (disable), then attempt again
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
}
|
||||
|
||||
//#then - 4 prompt attempts; 5th idle is skipped after 2 consecutive failures
|
||||
expect(promptMock).toHaveBeenCalledTimes(4)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should reset continuation failure state on session.compacted event", async () => {
|
||||
//#given - boulder state with incomplete plan and prompt always fails
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
|
||||
|
||||
const state: BoulderState = {
|
||||
active_plan: planPath,
|
||||
started_at: "2026-01-02T10:00:00Z",
|
||||
session_ids: [MAIN_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, state)
|
||||
|
||||
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
|
||||
const mockInput = createMockPluginInput({ promptMock })
|
||||
const hook = createAtlasHook(mockInput)
|
||||
|
||||
const originalDateNow = Date.now
|
||||
let now = 0
|
||||
Date.now = () => now
|
||||
|
||||
try {
|
||||
//#when - two failures disables continuation, then compaction resets it
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
await hook.handler({ event: { type: "session.compacted", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
now += 6000
|
||||
|
||||
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
|
||||
await flushMicrotasks()
|
||||
|
||||
//#then - 2 attempts + 1 after compaction (3 total)
|
||||
expect(promptMock).toHaveBeenCalledTimes(3)
|
||||
} finally {
|
||||
Date.now = originalDateNow
|
||||
}
|
||||
})
|
||||
|
||||
test("should cleanup on session.deleted", async () => {
|
||||
// given - boulder state
|
||||
const planPath = join(TEST_DIR, "test-plan.md")
|
||||
|
||||
@@ -26,6 +26,13 @@ function isSisyphusPath(filePath: string): boolean {
|
||||
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
function getLastAgentFromSession(sessionID: string): string | null {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return null
|
||||
const nearest = findNearestMessageWithFields(messageDir)
|
||||
return nearest?.agent?.toLowerCase() ?? null
|
||||
}
|
||||
|
||||
const DIRECT_WORK_REMINDER = `
|
||||
|
||||
---
|
||||
@@ -384,6 +391,7 @@ interface ToolExecuteAfterOutput {
|
||||
interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
lastContinuationInjectedAt?: number
|
||||
promptFailureCount: number
|
||||
}
|
||||
|
||||
const CONTINUATION_COOLDOWN_MS = 5000
|
||||
@@ -425,13 +433,14 @@ export function createAtlasHook(
|
||||
function getState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
state = { promptFailureCount: 0 }
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> {
|
||||
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise<void> {
|
||||
const state = getState(sessionID)
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -474,21 +483,28 @@ export function createAtlasHook(
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: "atlas",
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agent ?? "atlas",
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Boulder continuation failed`, { sessionID, error: String(err) })
|
||||
}
|
||||
}
|
||||
state.promptFailureCount = 0
|
||||
|
||||
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
|
||||
} catch (err) {
|
||||
state.promptFailureCount += 1
|
||||
log(`[${HOOK_NAME}] Boulder continuation failed`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
promptFailureCount: state.promptFailureCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
@@ -534,6 +550,14 @@ export function createAtlasHook(
|
||||
return
|
||||
}
|
||||
|
||||
if (state.promptFailureCount >= 2) {
|
||||
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, {
|
||||
sessionID,
|
||||
promptFailureCount: state.promptFailureCount,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -549,8 +573,14 @@ export function createAtlasHook(
|
||||
return
|
||||
}
|
||||
|
||||
if (!isCallerOrchestrator(sessionID)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID })
|
||||
const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
|
||||
const lastAgent = getLastAgentFromSession(sessionID)
|
||||
if (!lastAgent || lastAgent !== requiredAgent) {
|
||||
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
|
||||
sessionID,
|
||||
lastAgent: lastAgent ?? "unknown",
|
||||
requiredAgent,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -568,7 +598,7 @@ export function createAtlasHook(
|
||||
|
||||
state.lastContinuationInjectedAt = now
|
||||
const remaining = progress.total - progress.completed
|
||||
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total)
|
||||
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -618,6 +648,17 @@ export function createAtlasHook(
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as
|
||||
| string
|
||||
| undefined
|
||||
if (sessionID) {
|
||||
sessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
|
||||
}
|
||||
return
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as path from "node:path"
|
||||
import { CACHE_DIR, PACKAGE_NAME } from "./constants"
|
||||
import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface BunLockfile {
|
||||
@@ -17,7 +17,7 @@ function stripTrailingCommas(json: string): string {
|
||||
}
|
||||
|
||||
function removeFromBunLock(packageName: string): boolean {
|
||||
const lockPath = path.join(CACHE_DIR, "bun.lock")
|
||||
const lockPath = path.join(USER_CONFIG_DIR, "bun.lock")
|
||||
if (!fs.existsSync(lockPath)) return false
|
||||
|
||||
try {
|
||||
@@ -48,8 +48,8 @@ function removeFromBunLock(packageName: string): boolean {
|
||||
|
||||
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
|
||||
try {
|
||||
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName)
|
||||
const pkgJsonPath = path.join(CACHE_DIR, "package.json")
|
||||
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
|
||||
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
|
||||
|
||||
let packageRemoved = false
|
||||
let dependencyRemoved = false
|
||||
|
||||
@@ -16,12 +16,6 @@ function getCacheDir(): string {
|
||||
|
||||
export const CACHE_DIR = getCacheDir()
|
||||
export const VERSION_FILE = path.join(CACHE_DIR, "version")
|
||||
export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
CACHE_DIR,
|
||||
"node_modules",
|
||||
PACKAGE_NAME,
|
||||
"package.json"
|
||||
)
|
||||
|
||||
export function getWindowsAppdataDir(): string | null {
|
||||
if (process.platform !== "win32") return null
|
||||
@@ -31,3 +25,10 @@ export function getWindowsAppdataDir(): string | null {
|
||||
export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json")
|
||||
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
USER_CONFIG_DIR,
|
||||
"node_modules",
|
||||
PACKAGE_NAME,
|
||||
"package.json"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createCategorySkillReminderHook } from "./index"
|
||||
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import * as sharedModule from "../../shared"
|
||||
|
||||
describe("category-skill-reminder hook", () => {
|
||||
@@ -29,10 +30,14 @@ describe("category-skill-reminder hook", () => {
|
||||
} as any
|
||||
}
|
||||
|
||||
function createHook(availableSkills: AvailableSkill[] = []) {
|
||||
return createCategorySkillReminderHook(createMockPluginInput(), availableSkills)
|
||||
}
|
||||
|
||||
describe("target agent detection", () => {
|
||||
test("should inject reminder for sisyphus agent after 3 tool calls", async () => {
|
||||
// given - sisyphus agent session with multiple tool calls
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "sisyphus-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -52,7 +57,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should inject reminder for atlas agent", async () => {
|
||||
// given - atlas agent session
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "atlas-session"
|
||||
updateSessionAgent(sessionID, "Atlas")
|
||||
|
||||
@@ -71,7 +76,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should inject reminder for sisyphus-junior agent", async () => {
|
||||
// given - sisyphus-junior agent session
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "junior-session"
|
||||
updateSessionAgent(sessionID, "sisyphus-junior")
|
||||
|
||||
@@ -90,7 +95,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should NOT inject reminder for non-target agents", async () => {
|
||||
// given - librarian agent session (not a target)
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "librarian-session"
|
||||
updateSessionAgent(sessionID, "librarian")
|
||||
|
||||
@@ -109,7 +114,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should detect agent from input.agent when session state is empty", async () => {
|
||||
// given - no session state, agent provided in input
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "input-agent-session"
|
||||
|
||||
const output = { title: "", output: "result", metadata: {} }
|
||||
@@ -127,7 +132,7 @@ describe("category-skill-reminder hook", () => {
|
||||
describe("delegation tool tracking", () => {
|
||||
test("should NOT inject reminder if delegate_task is used", async () => {
|
||||
// given - sisyphus agent that uses delegate_task
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "delegation-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -147,7 +152,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should NOT inject reminder if call_omo_agent is used", async () => {
|
||||
// given - sisyphus agent that uses call_omo_agent
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "omo-agent-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -167,7 +172,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should NOT inject reminder if task tool is used", async () => {
|
||||
// given - sisyphus agent that uses task tool
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "task-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -189,7 +194,7 @@ describe("category-skill-reminder hook", () => {
|
||||
describe("tool call counting", () => {
|
||||
test("should NOT inject reminder before 3 tool calls", async () => {
|
||||
// given - sisyphus agent with only 2 tool calls
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "few-calls-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -207,7 +212,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should only inject reminder once per session", async () => {
|
||||
// given - sisyphus agent session
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "once-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -231,7 +236,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should only count delegatable work tools", async () => {
|
||||
// given - sisyphus agent with mixed tool calls
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "mixed-tools-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -252,7 +257,7 @@ describe("category-skill-reminder hook", () => {
|
||||
describe("event handling", () => {
|
||||
test("should reset state on session.deleted event", async () => {
|
||||
// given - sisyphus agent with reminder already shown
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "delete-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -278,7 +283,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should reset state on session.compacted event", async () => {
|
||||
// given - sisyphus agent with reminder already shown
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "compact-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -306,7 +311,7 @@ describe("category-skill-reminder hook", () => {
|
||||
describe("case insensitivity", () => {
|
||||
test("should handle tool names case-insensitively", async () => {
|
||||
// given - sisyphus agent with mixed case tool names
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "case-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -325,7 +330,7 @@ describe("category-skill-reminder hook", () => {
|
||||
|
||||
test("should handle delegation tool names case-insensitively", async () => {
|
||||
// given - sisyphus agent using DELEGATE_TASK in uppercase
|
||||
const hook = createCategorySkillReminderHook(createMockPluginInput())
|
||||
const hook = createHook()
|
||||
const sessionID = "case-delegate-session"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
|
||||
@@ -343,4 +348,71 @@ describe("category-skill-reminder hook", () => {
|
||||
clearSessionAgent(sessionID)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dynamic skills reminder message", () => {
|
||||
test("shows built-in skills when only built-in skills are available", async () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
|
||||
{ name: "git-master", description: "Git operations", location: "plugin" },
|
||||
{ name: "playwright", description: "Browser automation", location: "plugin" },
|
||||
]
|
||||
const hook = createHook(availableSkills)
|
||||
const sessionID = "builtins-only"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
const output = { title: "", output: "result", metadata: {} }
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("**Built-in**:")
|
||||
expect(output.output).toContain("frontend-ui-ux")
|
||||
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
|
||||
expect(output.output).toContain("load_skills=[\"frontend-ui-ux\"")
|
||||
})
|
||||
|
||||
test("emphasizes user skills with PRIORITY and uses first user skill in example", async () => {
|
||||
// given
|
||||
const availableSkills: AvailableSkill[] = [
|
||||
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
|
||||
{ name: "react-19", description: "React 19 expertise", location: "user" },
|
||||
{ name: "web-designer", description: "Visual design", location: "user" },
|
||||
]
|
||||
const hook = createHook(availableSkills)
|
||||
const sessionID = "user-skills"
|
||||
updateSessionAgent(sessionID, "Atlas")
|
||||
const output = { title: "", output: "result", metadata: {} }
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
|
||||
expect(output.output).toContain("react-19")
|
||||
expect(output.output).toContain("> User-installed skills OVERRIDE")
|
||||
expect(output.output).toContain("load_skills=[\"react-19\"")
|
||||
})
|
||||
|
||||
test("still injects a generic reminder when no skills are provided", async () => {
|
||||
// given
|
||||
const hook = createHook([])
|
||||
const sessionID = "no-skills"
|
||||
updateSessionAgent(sessionID, "Sisyphus")
|
||||
const output = { title: "", output: "result", metadata: {} }
|
||||
|
||||
// when
|
||||
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "1" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "2" }, output)
|
||||
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "3" }, output)
|
||||
|
||||
// then
|
||||
expect(output.output).toContain("[Category+Skill Reminder]")
|
||||
expect(output.output).toContain("load_skills=[]")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
@@ -34,33 +35,41 @@ const DELEGATION_TOOLS = new Set([
|
||||
"task",
|
||||
])
|
||||
|
||||
const REMINDER_MESSAGE = `
|
||||
[Category+Skill Reminder]
|
||||
function formatSkillNames(skills: AvailableSkill[], limit: number): string {
|
||||
if (skills.length === 0) return "(none)"
|
||||
const shown = skills.slice(0, limit).map((s) => s.name)
|
||||
const remaining = skills.length - shown.length
|
||||
const suffix = remaining > 0 ? ` (+${remaining} more)` : ""
|
||||
return shown.join(", ") + suffix
|
||||
}
|
||||
|
||||
You are an orchestrator agent. Consider whether this work should be delegated:
|
||||
function buildReminderMessage(availableSkills: AvailableSkill[]): string {
|
||||
const builtinSkills = availableSkills.filter((s) => s.location === "plugin")
|
||||
const customSkills = availableSkills.filter((s) => s.location !== "plugin")
|
||||
|
||||
**DELEGATE when:**
|
||||
- UI/Frontend work → category: "visual-engineering", skills: ["frontend-ui-ux"]
|
||||
- Complex logic/architecture → category: "ultrabrain"
|
||||
- Quick/trivial tasks → category: "quick"
|
||||
- Git operations → skills: ["git-master"]
|
||||
- Browser automation → skills: ["playwright"] or ["agent-browser"]
|
||||
const builtinText = formatSkillNames(builtinSkills, 8)
|
||||
const customText = formatSkillNames(customSkills, 8)
|
||||
|
||||
**DO IT YOURSELF when:**
|
||||
- Gathering context/exploring codebase
|
||||
- Simple edits that are part of a larger task you're coordinating
|
||||
- Tasks requiring your full context understanding
|
||||
const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name
|
||||
const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]"
|
||||
|
||||
Example delegation:
|
||||
\`\`\`
|
||||
delegate_task(
|
||||
category="visual-engineering",
|
||||
load_skills=["frontend-ui-ux"],
|
||||
description="Implement responsive navbar with animations",
|
||||
run_in_background=true
|
||||
)
|
||||
\`\`\`
|
||||
`
|
||||
const lines = [
|
||||
"",
|
||||
"[Category+Skill Reminder]",
|
||||
"",
|
||||
`**Built-in**: ${builtinText}`,
|
||||
`**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`,
|
||||
"",
|
||||
"> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.",
|
||||
"",
|
||||
"```typescript",
|
||||
`delegate_task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`,
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
@@ -81,8 +90,12 @@ interface SessionState {
|
||||
toolCallCount: number
|
||||
}
|
||||
|
||||
export function createCategorySkillReminderHook(_ctx: PluginInput) {
|
||||
export function createCategorySkillReminderHook(
|
||||
_ctx: PluginInput,
|
||||
availableSkills: AvailableSkill[] = []
|
||||
) {
|
||||
const sessionStates = new Map<string, SessionState>()
|
||||
const reminderMessage = buildReminderMessage(availableSkills)
|
||||
|
||||
function getOrCreateState(sessionID: string): SessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
@@ -130,7 +143,7 @@ export function createCategorySkillReminderHook(_ctx: PluginInput) {
|
||||
state.toolCallCount++
|
||||
|
||||
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
|
||||
output.output += REMINDER_MESSAGE
|
||||
output.output += reminderMessage
|
||||
state.reminderShown = true
|
||||
log("[category-skill-reminder] Reminder injected", {
|
||||
sessionID,
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands.
|
||||
|
||||
**Config Sources** (priority): `.claude/settings.json` (project) > `~/.claude/settings.json` (global)
|
||||
**Config Sources** (priority): `.claude/settings.local.json` > `.claude/settings.json` (project) > `~/.claude/settings.json` (global)
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
claude-code-hooks/
|
||||
├── index.ts # Main factory (401 lines)
|
||||
├── index.ts # Main factory (421 lines)
|
||||
├── config.ts # Loads ~/.claude/settings.json
|
||||
├── config-loader.ts # Extended config (disabledHooks)
|
||||
├── pre-tool-use.ts # PreToolUse executor
|
||||
@@ -19,6 +19,7 @@ claude-code-hooks/
|
||||
├── pre-compact.ts # PreCompact executor
|
||||
├── transcript.ts # Tool use recording
|
||||
├── tool-input-cache.ts # Pre→post input caching
|
||||
├── todo.ts # Todo integration
|
||||
└── types.ts # Hook & IO type definitions
|
||||
```
|
||||
|
||||
@@ -31,22 +32,16 @@ claude-code-hooks/
|
||||
| Stop | Session idle/end | Inject | sessionId, parentSessionId, cwd |
|
||||
| PreCompact | Before summarize | No | sessionId, cwd |
|
||||
|
||||
## CONFIG SOURCES
|
||||
Priority (highest first):
|
||||
1. `.claude/settings.local.json` (Project-local, git-ignored)
|
||||
2. `.claude/settings.json` (Project)
|
||||
3. `~/.claude/settings.json` (Global user)
|
||||
|
||||
## HOOK EXECUTION
|
||||
- **Matchers**: Hooks filter by tool name or event type via regex/glob.
|
||||
- **Commands**: Executed via subprocess with env vars (`$SESSION_ID`, `$TOOL_NAME`).
|
||||
- **Matchers**: Hooks filter by tool name or event type via regex/glob
|
||||
- **Commands**: Executed via subprocess with env vars (`$SESSION_ID`, `$TOOL_NAME`)
|
||||
- **Exit Codes**:
|
||||
- `0`: Pass (Success)
|
||||
- `1`: Warn (Continue with system message)
|
||||
- `2`: Block (Abort operation/prompt)
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- **Heavy PreToolUse**: Runs before EVERY tool; keep logic light to avoid latency.
|
||||
- **Blocking non-critical**: Prefer PostToolUse warnings for non-fatal issues.
|
||||
- **Direct state mutation**: Use `updatedInput` in PreToolUse instead of side effects.
|
||||
- **Ignoring Exit Codes**: Ensure scripts return `2` to properly block sensitive tools.
|
||||
- **Heavy PreToolUse**: Runs before EVERY tool; keep logic light to avoid latency
|
||||
- **Blocking non-critical**: Prefer PostToolUse warnings for non-fatal issues
|
||||
- **Direct state mutation**: Use `updatedInput` in PreToolUse instead of side effects
|
||||
- **Ignoring Exit Codes**: Ensure scripts return `2` to properly block sensitive tools
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
import { describe, expect, it, mock, beforeEach } from "bun:test"
|
||||
|
||||
// Mock dependencies before importing
|
||||
const mockInjectHookMessage = mock(() => true)
|
||||
mock.module("../../features/hook-message-injector", () => ({
|
||||
injectHookMessage: mockInjectHookMessage,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/logger", () => ({
|
||||
log: () => {},
|
||||
}))
|
||||
import { describe, expect, it, mock } from "bun:test"
|
||||
|
||||
mock.module("../../shared/system-directive", () => ({
|
||||
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
|
||||
@@ -25,78 +15,45 @@ mock.module("../../shared/system-directive", () => ({
|
||||
}))
|
||||
|
||||
import { createCompactionContextInjector } from "./index"
|
||||
import type { SummarizeContext } from "./index"
|
||||
|
||||
describe("createCompactionContextInjector", () => {
|
||||
beforeEach(() => {
|
||||
mockInjectHookMessage.mockClear()
|
||||
})
|
||||
|
||||
describe("Agent Verification State preservation", () => {
|
||||
it("includes Agent Verification State section in compaction prompt", async () => {
|
||||
// given
|
||||
//#given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.85,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
//#when
|
||||
const prompt = injector()
|
||||
|
||||
// then
|
||||
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1)
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Agent Verification State")
|
||||
expect(injectedPrompt).toContain("Current Agent")
|
||||
expect(injectedPrompt).toContain("Verification Progress")
|
||||
//#then
|
||||
expect(prompt).toContain("Agent Verification State")
|
||||
expect(prompt).toContain("Current Agent")
|
||||
expect(prompt).toContain("Verification Progress")
|
||||
})
|
||||
|
||||
it("includes Momus-specific context for reviewer agents", async () => {
|
||||
// given
|
||||
it("includes reviewer-agent continuity fields", async () => {
|
||||
//#given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.9,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
//#when
|
||||
const prompt = injector()
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Previous Rejections")
|
||||
expect(injectedPrompt).toContain("Acceptance Status")
|
||||
expect(injectedPrompt).toContain("reviewer agents")
|
||||
//#then
|
||||
expect(prompt).toContain("Previous Rejections")
|
||||
expect(prompt).toContain("Acceptance Status")
|
||||
expect(prompt).toContain("reviewer agents")
|
||||
})
|
||||
|
||||
it("preserves file verification progress in compaction prompt", async () => {
|
||||
// given
|
||||
it("preserves file verification progress fields", async () => {
|
||||
//#given
|
||||
const injector = createCompactionContextInjector()
|
||||
const context: SummarizeContext = {
|
||||
sessionID: "test-session",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-5",
|
||||
usageRatio: 0.95,
|
||||
directory: "/test/dir",
|
||||
}
|
||||
|
||||
// when
|
||||
await injector(context)
|
||||
//#when
|
||||
const prompt = injector()
|
||||
|
||||
// then
|
||||
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][]
|
||||
const injectedPrompt = calls[0]?.[1] ?? ""
|
||||
expect(injectedPrompt).toContain("Pending Verifications")
|
||||
expect(injectedPrompt).toContain("Files already verified")
|
||||
//#then
|
||||
expect(prompt).toContain("Pending Verifications")
|
||||
expect(prompt).toContain("Files already verified")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,6 @@
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
|
||||
export interface SummarizeContext {
|
||||
sessionID: string
|
||||
providerID: string
|
||||
modelID: string
|
||||
usageRatio: number
|
||||
directory: string
|
||||
}
|
||||
|
||||
const SUMMARIZE_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
@@ -58,19 +48,5 @@ This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
export function createCompactionContextInjector() {
|
||||
return async (ctx: SummarizeContext): Promise<void> => {
|
||||
log("[compaction-context-injector] injecting context", { sessionID: ctx.sessionID })
|
||||
|
||||
const success = injectHookMessage(ctx.sessionID, SUMMARIZE_CONTEXT_PROMPT, {
|
||||
agent: "general",
|
||||
model: { providerID: ctx.providerID, modelID: ctx.modelID },
|
||||
path: { cwd: ctx.directory },
|
||||
})
|
||||
|
||||
if (success) {
|
||||
log("[compaction-context-injector] context injected", { sessionID: ctx.sessionID })
|
||||
} else {
|
||||
log("[compaction-context-injector] injection failed", { sessionID: ctx.sessionID })
|
||||
}
|
||||
}
|
||||
return (): string => COMPACTION_CONTEXT_PROMPT
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ export { createDelegateTaskRetryHook } from "./delegate-task-retry";
|
||||
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
|
||||
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
|
||||
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
|
||||
export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector";
|
||||
export { createCompactionContextInjector } from "./compaction-context-injector";
|
||||
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
|
||||
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
|
||||
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
|
||||
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("preemptive-compaction", () => {
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-5",
|
||||
modelID: "claude-opus-4-6",
|
||||
tokens: {
|
||||
input: 180000,
|
||||
output: 0,
|
||||
@@ -60,6 +60,41 @@ describe("preemptive-compaction", () => {
|
||||
expect(summarize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("triggers summarize for non-anthropic providers when usage exceeds threshold", async () => {
|
||||
//#given
|
||||
const messages = mock(() =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5.2",
|
||||
tokens: {
|
||||
input: 180000,
|
||||
output: 0,
|
||||
reasoning: 0,
|
||||
cache: { read: 0, write: 0 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
const summarize = mock(() => Promise.resolve())
|
||||
const hook = createPreemptiveCompactionHook(createMockCtx({ messages, summarize }))
|
||||
const output = { title: "", output: "", metadata: {} }
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "Read", sessionID, callID: "call-3" },
|
||||
output
|
||||
)
|
||||
|
||||
//#then
|
||||
expect(summarize).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("does not summarize when usage is below threshold", async () => {
|
||||
// #given
|
||||
const messages = mock(() =>
|
||||
@@ -69,7 +104,7 @@ describe("preemptive-compaction", () => {
|
||||
info: {
|
||||
role: "assistant",
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-5",
|
||||
modelID: "claude-opus-4-6",
|
||||
tokens: {
|
||||
input: 100000,
|
||||
output: 0,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
const DEFAULT_ACTUAL_LIMIT = 200_000
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
: DEFAULT_ACTUAL_LIMIT
|
||||
|
||||
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
|
||||
|
||||
@@ -59,11 +61,14 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
|
||||
if (assistantMessages.length === 0) return
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (lastAssistant.providerID !== "anthropic") return
|
||||
const actualLimit =
|
||||
lastAssistant.providerID === "anthropic"
|
||||
? ANTHROPIC_ACTUAL_LIMIT
|
||||
: DEFAULT_ACTUAL_LIMIT
|
||||
|
||||
const lastTokens = lastAssistant.tokens
|
||||
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
|
||||
const usageRatio = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT
|
||||
const usageRatio = totalInputTokens / actualLimit
|
||||
|
||||
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return
|
||||
|
||||
|
||||
@@ -352,6 +352,121 @@ describe("prometheus-md-only", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("boulder state priority over message files (fixes #927)", () => {
|
||||
const BOULDER_DIR = join(tmpdir(), `boulder-test-${randomUUID()}`)
|
||||
const BOULDER_FILE = join(BOULDER_DIR, ".sisyphus", "boulder.json")
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(join(BOULDER_DIR, ".sisyphus"), { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(BOULDER_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
//#given session was started with prometheus (first message), but /start-work set boulder agent to atlas
|
||||
//#when user types "continue" after interruption (memory cleared, falls back to message files)
|
||||
//#then should use boulder state agent (atlas), not message file agent (prometheus)
|
||||
test("should prioritize boulder agent over message file agent", async () => {
|
||||
// given - prometheus in message files (from /plan)
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
|
||||
// given - atlas in boulder state (from /start-work)
|
||||
writeFileSync(BOULDER_FILE, JSON.stringify({
|
||||
active_plan: "/test/plan.md",
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: [TEST_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "atlas"
|
||||
}))
|
||||
|
||||
const hook = createPrometheusMdOnlyHook({
|
||||
client: {},
|
||||
directory: BOULDER_DIR,
|
||||
} as never)
|
||||
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/code.ts" },
|
||||
}
|
||||
|
||||
// when / then - should NOT block because boulder says atlas, not prometheus
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should use prometheus from boulder state when set", async () => {
|
||||
// given - atlas in message files (from some other agent)
|
||||
setupMessageStorage(TEST_SESSION_ID, "atlas")
|
||||
|
||||
// given - prometheus in boulder state (edge case, but should honor it)
|
||||
writeFileSync(BOULDER_FILE, JSON.stringify({
|
||||
active_plan: "/test/plan.md",
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: [TEST_SESSION_ID],
|
||||
plan_name: "test-plan",
|
||||
agent: "prometheus"
|
||||
}))
|
||||
|
||||
const hook = createPrometheusMdOnlyHook({
|
||||
client: {},
|
||||
directory: BOULDER_DIR,
|
||||
} as never)
|
||||
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/code.ts" },
|
||||
}
|
||||
|
||||
// when / then - should block because boulder says prometheus
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should fall back to message files when session not in boulder", async () => {
|
||||
// given - prometheus in message files
|
||||
setupMessageStorage(TEST_SESSION_ID, "prometheus")
|
||||
|
||||
// given - boulder state exists but for different session
|
||||
writeFileSync(BOULDER_FILE, JSON.stringify({
|
||||
active_plan: "/test/plan.md",
|
||||
started_at: new Date().toISOString(),
|
||||
session_ids: ["other-session-id"],
|
||||
plan_name: "test-plan",
|
||||
agent: "atlas"
|
||||
}))
|
||||
|
||||
const hook = createPrometheusMdOnlyHook({
|
||||
client: {},
|
||||
directory: BOULDER_DIR,
|
||||
} as never)
|
||||
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/path/to/code.ts" },
|
||||
}
|
||||
|
||||
// when / then - should block because falls back to message files (prometheus)
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
})
|
||||
|
||||
describe("without message storage", () => {
|
||||
test("should handle missing session gracefully (no agent found)", async () => {
|
||||
// given
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join, resolve, relative, isAbsolute } from "node:path"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { readBoulderState } from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
@@ -70,8 +71,31 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined {
|
||||
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
function getAgentFromSession(sessionID: string): string | undefined {
|
||||
return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID)
|
||||
/**
|
||||
* Get the effective agent for the session.
|
||||
* Priority order:
|
||||
* 1. In-memory session agent (most recent, set by /start-work)
|
||||
* 2. Boulder state agent (persisted across restarts, fixes #927)
|
||||
* 3. Message files (fallback for sessions without boulder state)
|
||||
*
|
||||
* This fixes issue #927 where after interruption:
|
||||
* - In-memory map is cleared (process restart)
|
||||
* - Message files return "prometheus" (oldest message from /plan)
|
||||
* - But boulder.json has agent: "atlas" (set by /start-work)
|
||||
*/
|
||||
function getAgentFromSession(sessionID: string, directory: string): string | undefined {
|
||||
// Check in-memory first (current session)
|
||||
const memoryAgent = getSessionAgent(sessionID)
|
||||
if (memoryAgent) return memoryAgent
|
||||
|
||||
// Check boulder state (persisted across restarts) - fixes #927
|
||||
const boulderState = readBoulderState(directory)
|
||||
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
|
||||
return boulderState.agent
|
||||
}
|
||||
|
||||
// Fallback to message files
|
||||
return getAgentFromMessageFiles(sessionID)
|
||||
}
|
||||
|
||||
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
@@ -80,7 +104,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID)
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
|
||||
if (agentName !== PROMETHEUS_AGENT) {
|
||||
return
|
||||
|
||||
@@ -102,7 +102,7 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||
if (existingState) {
|
||||
clearBoulderState(ctx.directory)
|
||||
}
|
||||
const newState = createBoulderState(matchedPlan, sessionId)
|
||||
const newState = createBoulderState(matchedPlan, sessionId, "atlas")
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
@@ -187,7 +187,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
|
||||
} else if (incompletePlans.length === 1) {
|
||||
const planPath = incompletePlans[0]
|
||||
const progress = getPlanProgress(planPath)
|
||||
const newState = createBoulderState(planPath, sessionId)
|
||||
const newState = createBoulderState(planPath, sessionId, "atlas")
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo += `
|
||||
|
||||
@@ -41,7 +41,7 @@ describe("createThinkModeHook integration", () => {
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-6",
|
||||
"Please think deeply about this problem"
|
||||
)
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("createThinkModeHook integration", () => {
|
||||
|
||||
// then should upgrade to high variant and inject thinking config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
expect((message.thinking as Record<string, unknown>)?.type).toBe(
|
||||
"enabled"
|
||||
@@ -61,11 +61,11 @@ describe("createThinkModeHook integration", () => {
|
||||
})
|
||||
|
||||
it("should handle github-copilot Claude with dots in version", async () => {
|
||||
// given a github-copilot Claude model with dot format (claude-opus-4.5)
|
||||
// given a github-copilot Claude model with dot format (claude-opus-4.6)
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4.5",
|
||||
"claude-opus-4.6",
|
||||
"ultrathink mode"
|
||||
)
|
||||
|
||||
@@ -74,7 +74,7 @@ describe("createThinkModeHook integration", () => {
|
||||
|
||||
// then should upgrade to high variant (hyphen format)
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ describe("createThinkModeHook integration", () => {
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5",
|
||||
"claude-opus-4-6",
|
||||
"Just do this task"
|
||||
)
|
||||
const originalModelID = input.message.model?.modelID
|
||||
@@ -271,7 +271,7 @@ describe("createThinkModeHook integration", () => {
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5-high",
|
||||
"claude-opus-4-6-high",
|
||||
"think deeply"
|
||||
)
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("createThinkModeHook integration", () => {
|
||||
|
||||
// then should NOT modify the model (already high)
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
|
||||
// No additional thinking config should be injected
|
||||
expect(message.thinking).toBeUndefined()
|
||||
})
|
||||
@@ -341,13 +341,13 @@ describe("createThinkModeHook integration", () => {
|
||||
it("should handle empty prompt gracefully", async () => {
|
||||
// given empty prompt
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput("github-copilot", "claude-opus-4-5", "")
|
||||
const input = createMockInput("github-copilot", "claude-opus-4-6", "")
|
||||
|
||||
// when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// then should not upgrade (no think keyword)
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5")
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-6")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ describe("think-mode switcher", () => {
|
||||
it("should resolve github-copilot Claude Opus to anthropic config", () => {
|
||||
// given a github-copilot provider with Claude Opus model
|
||||
const providerID = "github-copilot"
|
||||
const modelID = "claude-opus-4-5"
|
||||
const modelID = "claude-opus-4-6"
|
||||
|
||||
// when getting thinking config
|
||||
const config = getThinkingConfig(providerID, modelID)
|
||||
@@ -38,8 +38,8 @@ describe("think-mode switcher", () => {
|
||||
})
|
||||
|
||||
it("should handle Claude with dots in version number", () => {
|
||||
// given a model ID with dots (claude-opus-4.5)
|
||||
const config = getThinkingConfig("github-copilot", "claude-opus-4.5")
|
||||
// given a model ID with dots (claude-opus-4.6)
|
||||
const config = getThinkingConfig("github-copilot", "claude-opus-4.6")
|
||||
|
||||
// then should still return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
@@ -127,18 +127,26 @@ describe("think-mode switcher", () => {
|
||||
describe("getHighVariant with dots vs hyphens", () => {
|
||||
it("should handle dots in Claude version numbers", () => {
|
||||
// given a Claude model ID with dot format
|
||||
const variant = getHighVariant("claude-opus-4.5")
|
||||
const variant = getHighVariant("claude-opus-4.6")
|
||||
|
||||
// then should return high variant with hyphen format
|
||||
expect(variant).toBe("claude-opus-4-5-high")
|
||||
expect(variant).toBe("claude-opus-4-6-high")
|
||||
})
|
||||
|
||||
it("should handle hyphens in Claude version numbers", () => {
|
||||
// given a Claude model ID with hyphen format
|
||||
const variant = getHighVariant("claude-opus-4-5")
|
||||
const variant = getHighVariant("claude-opus-4-6")
|
||||
|
||||
// then should return high variant
|
||||
expect(variant).toBe("claude-opus-4-5-high")
|
||||
expect(variant).toBe("claude-opus-4-6-high")
|
||||
})
|
||||
|
||||
it("should handle claude-opus-4-6 high variant", () => {
|
||||
// given a Claude Opus 4.6 model ID
|
||||
const variant = getHighVariant("claude-opus-4-6")
|
||||
|
||||
// then should return high variant
|
||||
expect(variant).toBe("claude-opus-4-6-high")
|
||||
})
|
||||
|
||||
it("should handle dots in GPT version numbers", () => {
|
||||
@@ -169,7 +177,7 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should return null for already-high variants", () => {
|
||||
// given model IDs that are already high variants
|
||||
expect(getHighVariant("claude-opus-4-5-high")).toBeNull()
|
||||
expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
|
||||
expect(getHighVariant("gpt-5-2-high")).toBeNull()
|
||||
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
|
||||
})
|
||||
@@ -185,7 +193,7 @@ describe("think-mode switcher", () => {
|
||||
describe("isAlreadyHighVariant", () => {
|
||||
it("should detect -high suffix", () => {
|
||||
// given model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("claude-opus-4-5-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
|
||||
})
|
||||
@@ -197,8 +205,8 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should return false for base models", () => {
|
||||
// given base model IDs without -high suffix
|
||||
expect(isAlreadyHighVariant("claude-opus-4-5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("claude-opus-4.5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
|
||||
expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
|
||||
})
|
||||
@@ -214,7 +222,7 @@ describe("think-mode switcher", () => {
|
||||
it("should return null for already-high variants", () => {
|
||||
// given already-high model variants
|
||||
expect(
|
||||
getThinkingConfig("anthropic", "claude-opus-4-5-high")
|
||||
getThinkingConfig("anthropic", "claude-opus-4-6-high")
|
||||
).toBeNull()
|
||||
expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull()
|
||||
expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull()
|
||||
@@ -223,7 +231,7 @@ describe("think-mode switcher", () => {
|
||||
it("should return null for already-high variants via github-copilot", () => {
|
||||
// given already-high model variants via github-copilot
|
||||
expect(
|
||||
getThinkingConfig("github-copilot", "claude-opus-4-5-high")
|
||||
getThinkingConfig("github-copilot", "claude-opus-4-6-high")
|
||||
).toBeNull()
|
||||
expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull()
|
||||
})
|
||||
@@ -250,7 +258,7 @@ describe("think-mode switcher", () => {
|
||||
describe("Direct provider configs (backwards compatibility)", () => {
|
||||
it("should still work for direct anthropic provider", () => {
|
||||
// given direct anthropic provider
|
||||
const config = getThinkingConfig("anthropic", "claude-opus-4-5")
|
||||
const config = getThinkingConfig("anthropic", "claude-opus-4-6")
|
||||
|
||||
// then should return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
@@ -343,10 +351,10 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should handle prefixes with dots in version numbers", () => {
|
||||
// given a model ID with prefix and dots
|
||||
const variant = getHighVariant("vertex_ai/claude-opus-4.5")
|
||||
const variant = getHighVariant("vertex_ai/claude-opus-4.6")
|
||||
|
||||
// then should normalize dots and preserve prefix
|
||||
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
expect(variant).toBe("vertex_ai/claude-opus-4-6-high")
|
||||
})
|
||||
|
||||
it("should handle multiple different prefixes", () => {
|
||||
@@ -364,7 +372,7 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should return null for already-high prefixed models", () => {
|
||||
// given prefixed model IDs that are already high
|
||||
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull()
|
||||
expect(getHighVariant("vertex_ai/claude-opus-4-6-high")).toBeNull()
|
||||
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -372,14 +380,14 @@ describe("think-mode switcher", () => {
|
||||
describe("isAlreadyHighVariant with prefixes", () => {
|
||||
it("should detect -high suffix in prefixed models", () => {
|
||||
// given prefixed model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for prefixed base models", () => {
|
||||
// given prefixed base model IDs without -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6")).toBe(false)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -402,7 +410,7 @@ describe("think-mode switcher", () => {
|
||||
it("should work with prefixed models on known providers", () => {
|
||||
// given known provider (anthropic) with prefixed model
|
||||
// This tests that the base model name is correctly extracted for capability check
|
||||
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5")
|
||||
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-6")
|
||||
|
||||
// then should return thinking config (base model is capable)
|
||||
expect(config).not.toBeNull()
|
||||
@@ -411,7 +419,7 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should return null for prefixed models that are already high", () => {
|
||||
// given prefixed already-high model
|
||||
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high")
|
||||
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-6-high")
|
||||
|
||||
// then should return null
|
||||
expect(config).toBeNull()
|
||||
@@ -444,11 +452,11 @@ describe("think-mode switcher", () => {
|
||||
|
||||
it("should not break when switching to high variant in think mode", () => {
|
||||
// given think mode switching vertex_ai/claude model to high variant
|
||||
const original = "vertex_ai/claude-opus-4-5"
|
||||
const original = "vertex_ai/claude-opus-4-6"
|
||||
const high = getHighVariant(original)
|
||||
|
||||
// then the high variant should be valid
|
||||
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
expect(high).toBe("vertex_ai/claude-opus-4-6-high")
|
||||
|
||||
// #and should be recognized as already high
|
||||
expect(isAlreadyHighVariant(high!)).toBe(true)
|
||||
|
||||
@@ -38,14 +38,14 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } {
|
||||
|
||||
/**
|
||||
* Normalizes model IDs to use consistent hyphen formatting.
|
||||
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5).
|
||||
* 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.5") // "claude-opus-4-5"
|
||||
* 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.5") // "vertex_ai/claude-opus-4-5"
|
||||
* 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
|
||||
@@ -59,10 +59,10 @@ function normalizeModelID(modelID: string): string {
|
||||
* model provider (Anthropic, Google, OpenAI).
|
||||
*
|
||||
* @example
|
||||
* resolveProvider("github-copilot", "claude-opus-4-5") // "anthropic"
|
||||
* resolveProvider("github-copilot", "claude-opus-4-6") // "anthropic"
|
||||
* resolveProvider("github-copilot", "gemini-3-pro") // "google"
|
||||
* resolveProvider("github-copilot", "gpt-5.2") // "openai"
|
||||
* resolveProvider("anthropic", "claude-opus-4-5") // "anthropic" (unchanged)
|
||||
* resolveProvider("anthropic", "claude-opus-4-6") // "anthropic" (unchanged)
|
||||
*/
|
||||
function resolveProvider(providerID: string, modelID: string): string {
|
||||
// GitHub Copilot is a proxy - infer actual provider from model name
|
||||
@@ -88,7 +88,7 @@ function resolveProvider(providerID: string, modelID: string): string {
|
||||
const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
// Claude
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5": "claude-opus-4-5-high",
|
||||
"claude-opus-4-6": "claude-opus-4-6-high",
|
||||
// Gemini
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
|
||||
206
src/hooks/write-existing-file-guard/index.test.ts
Normal file
206
src/hooks/write-existing-file-guard/index.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createWriteExistingFileGuardHook } from "./index"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as os from "os"
|
||||
|
||||
describe("createWriteExistingFileGuardHook", () => {
|
||||
let tempDir: string
|
||||
let ctx: { directory: string }
|
||||
let hook: ReturnType<typeof createWriteExistingFileGuardHook>
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-"))
|
||||
ctx = { directory: tempDir }
|
||||
hook = createWriteExistingFileGuardHook(ctx as any)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("tool.execute.before", () => {
|
||||
test("allows write to non-existing file", async () => {
|
||||
//#given
|
||||
const nonExistingFile = path.join(tempDir, "new-file.txt")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: nonExistingFile, content: "hello" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("blocks write to existing file", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: existingFile, content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("blocks write tool (lowercase) to existing file", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: existingFile, content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("ignores non-write tools", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Edit", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: existingFile, content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("ignores tools without any file path arg", async () => {
|
||||
//#given
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { command: "ls" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
describe("alternative arg names", () => {
|
||||
test("blocks write using 'path' arg to existing file", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { path: existingFile, content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("blocks write using 'file_path' arg to existing file", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { file_path: existingFile, content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("allows write using 'path' arg to non-existing file", async () => {
|
||||
//#given
|
||||
const nonExistingFile = path.join(tempDir, "new-file.txt")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { path: nonExistingFile, content: "hello" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("allows write using 'file_path' arg to non-existing file", async () => {
|
||||
//#given
|
||||
const nonExistingFile = path.join(tempDir, "new-file.txt")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { file_path: nonExistingFile, content: "hello" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("relative path resolution using ctx.directory", () => {
|
||||
test("blocks write to existing file using relative path", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "existing-file.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: "existing-file.txt", content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("allows write to non-existing file using relative path", async () => {
|
||||
//#given
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: "new-file.txt", content: "hello" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("blocks write to nested relative path when file exists", async () => {
|
||||
//#given
|
||||
const subDir = path.join(tempDir, "subdir")
|
||||
fs.mkdirSync(subDir)
|
||||
const existingFile = path.join(subDir, "existing.txt")
|
||||
fs.writeFileSync(existingFile, "existing content")
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: "subdir/existing.txt", content: "new content" } }
|
||||
|
||||
//#when
|
||||
const result = hook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
|
||||
test("uses ctx.directory not process.cwd for relative path resolution", async () => {
|
||||
//#given
|
||||
const existingFile = path.join(tempDir, "test-file.txt")
|
||||
fs.writeFileSync(existingFile, "content")
|
||||
const differentCtx = { directory: tempDir }
|
||||
const differentHook = createWriteExistingFileGuardHook(differentCtx as any)
|
||||
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
|
||||
const output = { args: { filePath: "test-file.txt", content: "new" } }
|
||||
|
||||
//#when
|
||||
const result = differentHook["tool.execute.before"]?.(input as any, output as any)
|
||||
|
||||
//#then
|
||||
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
33
src/hooks/write-existing-file-guard/index.ts
Normal file
33
src/hooks/write-existing-file-guard/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync } from "fs"
|
||||
import { resolve, isAbsolute } from "path"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "write") {
|
||||
return
|
||||
}
|
||||
|
||||
const args = output.args as { filePath?: string; path?: string; file_path?: string } | undefined
|
||||
const filePath = args?.filePath ?? args?.path ?? args?.file_path
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
log("[write-existing-file-guard] Blocking write to existing file", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
resolvedPath,
|
||||
})
|
||||
|
||||
throw new Error("File already exists. Use edit tool instead.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
21
src/index.compaction-model-agnostic.static.test.ts
Normal file
21
src/index.compaction-model-agnostic.static.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
describe("experimental.session.compacting", () => {
|
||||
test("does not hardcode a model and uses output.context", () => {
|
||||
//#given
|
||||
const indexUrl = new URL("./index.ts", import.meta.url)
|
||||
const content = readFileSync(indexUrl, "utf-8")
|
||||
const hookIndex = content.indexOf('"experimental.session.compacting"')
|
||||
|
||||
//#when
|
||||
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : ""
|
||||
|
||||
//#then
|
||||
expect(hookIndex).toBeGreaterThanOrEqual(0)
|
||||
expect(content.includes('modelID: "claude-opus-4-6"')).toBe(false)
|
||||
expect(hookSlice.includes("output.context.push")).toBe(true)
|
||||
expect(hookSlice.includes("providerID:")).toBe(false)
|
||||
expect(hookSlice.includes("modelID:")).toBe(false)
|
||||
})
|
||||
})
|
||||
150
src/index.ts
150
src/index.ts
@@ -1,4 +1,5 @@
|
||||
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
|
||||
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
createUnstableAgentBabysitterHook,
|
||||
createPreemptiveCompactionHook,
|
||||
createTasksTodowriteDisablerHook,
|
||||
createWriteExistingFileGuardHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
discoverOpencodeProjectSkills,
|
||||
mergeSkills,
|
||||
} from "./features/opencode-skill-loader";
|
||||
import type { SkillScope } from "./features/opencode-skill-loader/types";
|
||||
import { createBuiltinSkills } from "./features/builtin-skills";
|
||||
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
|
||||
import {
|
||||
@@ -83,6 +86,10 @@ import {
|
||||
createTaskList,
|
||||
createTaskUpdateTool,
|
||||
} from "./tools";
|
||||
import {
|
||||
CATEGORY_DESCRIPTIONS,
|
||||
DEFAULT_CATEGORIES,
|
||||
} from "./tools/delegate-task/constants";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
@@ -100,6 +107,7 @@ import {
|
||||
OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
|
||||
injectServerAuthIntoClient,
|
||||
} from "./shared";
|
||||
import { filterDisabledTools } from "./shared/disabled-tools";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
@@ -114,6 +122,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
|
||||
const firstMessageVariantGate = createFirstMessageVariantGate();
|
||||
|
||||
const tmuxConfig = {
|
||||
@@ -241,9 +250,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createThinkingBlockValidatorHook()
|
||||
: null;
|
||||
|
||||
const categorySkillReminder = isHookEnabled("category-skill-reminder")
|
||||
? createCategorySkillReminderHook(ctx)
|
||||
: null;
|
||||
let categorySkillReminder: ReturnType<typeof createCategorySkillReminderHook> | null = null;
|
||||
|
||||
const ralphLoop = isHookEnabled("ralph-loop")
|
||||
? createRalphLoopHook(ctx, {
|
||||
@@ -280,6 +287,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const questionLabelTruncator = createQuestionLabelTruncatorHook();
|
||||
const subagentQuestionBlocker = createSubagentQuestionBlockerHook();
|
||||
const writeExistingFileGuard = isHookEnabled("write-existing-file-guard")
|
||||
? createWriteExistingFileGuardHook(ctx)
|
||||
: null;
|
||||
|
||||
const taskResumeInfo = createTaskResumeInfoHook();
|
||||
|
||||
@@ -389,33 +399,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const browserProvider =
|
||||
pluginConfig.browser_automation_engine?.provider ?? "playwright";
|
||||
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
|
||||
const delegateTask = createDelegateTask({
|
||||
manager: backgroundManager,
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
userCategories: pluginConfig.categories,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
|
||||
browserProvider,
|
||||
disabledSkills,
|
||||
onSyncSessionCreated: async (event) => {
|
||||
log("[index] onSyncSessionCreated callback", {
|
||||
sessionID: event.sessionID,
|
||||
parentID: event.parentID,
|
||||
title: event.title,
|
||||
});
|
||||
await tmuxSessionManager.onSessionCreated({
|
||||
type: "session.created",
|
||||
properties: {
|
||||
info: {
|
||||
id: event.sessionID,
|
||||
parentID: event.parentID,
|
||||
title: event.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const systemMcpNames = getSystemMcpServerNames();
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => {
|
||||
if (skill.mcpConfig) {
|
||||
@@ -442,6 +425,68 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
projectSkills,
|
||||
opencodeProjectSkills,
|
||||
);
|
||||
|
||||
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
if (scope === "user" || scope === "opencode") return "user";
|
||||
if (scope === "project" || scope === "opencode-project") return "project";
|
||||
return "plugin";
|
||||
}
|
||||
|
||||
const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.definition.description ?? "",
|
||||
location: mapScopeToLocation(skill.scope),
|
||||
}));
|
||||
|
||||
const mergedCategories = pluginConfig.categories
|
||||
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
|
||||
: DEFAULT_CATEGORIES;
|
||||
|
||||
const availableCategories = Object.entries(mergedCategories).map(
|
||||
([name, categoryConfig]) => ({
|
||||
name,
|
||||
description:
|
||||
pluginConfig.categories?.[name]?.description
|
||||
?? CATEGORY_DESCRIPTIONS[name]
|
||||
?? "General tasks",
|
||||
model: categoryConfig.model,
|
||||
}),
|
||||
);
|
||||
|
||||
const delegateTask = createDelegateTask({
|
||||
manager: backgroundManager,
|
||||
client: ctx.client,
|
||||
directory: ctx.directory,
|
||||
userCategories: pluginConfig.categories,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
|
||||
browserProvider,
|
||||
disabledSkills,
|
||||
availableCategories,
|
||||
availableSkills,
|
||||
onSyncSessionCreated: async (event) => {
|
||||
log("[index] onSyncSessionCreated callback", {
|
||||
sessionID: event.sessionID,
|
||||
parentID: event.parentID,
|
||||
title: event.title,
|
||||
});
|
||||
await tmuxSessionManager.onSessionCreated({
|
||||
type: "session.created",
|
||||
properties: {
|
||||
info: {
|
||||
id: event.sessionID,
|
||||
parentID: event.parentID,
|
||||
title: event.title,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
categorySkillReminder = isHookEnabled("category-skill-reminder")
|
||||
? createCategorySkillReminderHook(ctx, availableSkills)
|
||||
: null;
|
||||
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
const skillTool = createSkillTool({
|
||||
@@ -483,19 +528,26 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
: {};
|
||||
|
||||
const allTools: Record<string, ToolDefinition> = {
|
||||
...builtinTools,
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
delegate_task: delegateTask,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
};
|
||||
|
||||
const filteredTools: Record<string, ToolDefinition> = filterDisabledTools(
|
||||
allTools,
|
||||
pluginConfig.disabled_tools,
|
||||
);
|
||||
|
||||
return {
|
||||
tool: {
|
||||
...builtinTools,
|
||||
...backgroundTools,
|
||||
call_omo_agent: callOmoAgent,
|
||||
...(lookAt ? { look_at: lookAt } : {}),
|
||||
delegate_task: delegateTask,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
...taskToolsRecord,
|
||||
},
|
||||
tool: filteredTools,
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
if (input.agent) {
|
||||
@@ -720,6 +772,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await subagentQuestionBlocker["tool.execute.before"]?.(input, output);
|
||||
await writeExistingFileGuard?.["tool.execute.before"]?.(input, output);
|
||||
await questionLabelTruncator["tool.execute.before"]?.(input, output);
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await nonInteractiveEnv?.["tool.execute.before"](input, output);
|
||||
@@ -837,17 +890,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await taskResumeInfo["tool.execute.after"](input, output);
|
||||
},
|
||||
|
||||
"experimental.session.compacting": async (input: { sessionID: string }) => {
|
||||
"experimental.session.compacting": async (
|
||||
_input: { sessionID: string },
|
||||
output: { context: string[] },
|
||||
): Promise<void> => {
|
||||
if (!compactionContextInjector) {
|
||||
return;
|
||||
}
|
||||
await compactionContextInjector({
|
||||
sessionID: input.sessionID,
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-opus-4-5",
|
||||
usageRatio: 0.8,
|
||||
directory: ctx.directory,
|
||||
});
|
||||
output.context.push(compactionContextInjector());
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ Tier 1 of three-tier MCP system: 3 built-in remote HTTP MCPs.
|
||||
```
|
||||
mcp/
|
||||
├── index.ts # createBuiltinMcps() factory
|
||||
├── websearch.ts # Exa AI web search
|
||||
├── websearch.ts # Exa AI / Tavily web search
|
||||
├── context7.ts # Library documentation
|
||||
├── grep-app.ts # GitHub code search
|
||||
├── types.ts # McpNameSchema
|
||||
@@ -26,26 +26,16 @@ mcp/
|
||||
| Name | URL | Purpose | Auth |
|
||||
|------|-----|---------|------|
|
||||
| websearch | mcp.exa.ai / mcp.tavily.com | Real-time web search | EXA_API_KEY / TAVILY_API_KEY |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY (optional) |
|
||||
| grep_app | mcp.grep.app | GitHub code search | None |
|
||||
|
||||
## THREE-TIER MCP SYSTEM
|
||||
|
||||
1. **Built-in** (this directory): websearch, context7, grep_app
|
||||
2. **Claude Code compat**: `.mcp.json` with `${VAR}` expansion
|
||||
3. **Skill-embedded**: YAML frontmatter in skills (handled by skill-mcp-manager)
|
||||
|
||||
## Websearch Provider Configuration
|
||||
|
||||
The `websearch` MCP supports multiple providers. Exa is the default for backward compatibility and works without an API key.
|
||||
|
||||
| Provider | URL | Auth | API Key Required |
|
||||
|----------|-----|------|------------------|
|
||||
| exa (default) | mcp.exa.ai | x-api-key header | No (optional) |
|
||||
| tavily | mcp.tavily.com | Authorization Bearer | Yes |
|
||||
|
||||
### Configuration Example
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"websearch": {
|
||||
@@ -54,17 +44,6 @@ The `websearch` MCP supports multiple providers. Exa is the default for backward
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `EXA_API_KEY`: Optional. Used when provider is `exa`.
|
||||
- `TAVILY_API_KEY`: Required when provider is `tavily`.
|
||||
|
||||
### Priority and Behavior
|
||||
|
||||
- **Default**: Exa is used if no provider is specified.
|
||||
- **Backward Compatibility**: Existing setups using `EXA_API_KEY` continue to work without changes.
|
||||
- **Validation**: Selecting `tavily` without providing `TAVILY_API_KEY` will result in a configuration error.
|
||||
|
||||
## CONFIG PATTERN
|
||||
|
||||
```typescript
|
||||
@@ -77,15 +56,6 @@ export const mcp_name = {
|
||||
}
|
||||
```
|
||||
|
||||
## USAGE
|
||||
|
||||
```typescript
|
||||
import { createBuiltinMcps } from "./mcp"
|
||||
|
||||
const mcps = createBuiltinMcps() // Enable all
|
||||
const mcps = createBuiltinMcps(["websearch"]) // Disable specific
|
||||
```
|
||||
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/mcp/my-mcp.ts`
|
||||
@@ -96,6 +66,5 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
|
||||
|
||||
- **Remote only**: HTTP/SSE, no stdio
|
||||
- **Disable**: User can set `disabled_mcps: ["name"]` in config
|
||||
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var
|
||||
- **Exa**: Optional auth using `EXA_API_KEY` env var
|
||||
- **Exa**: Default provider, works without API key
|
||||
- **Tavily**: Requires `TAVILY_API_KEY` env var
|
||||
|
||||
@@ -23,12 +23,6 @@ beforeEach(() => {
|
||||
oracle: { name: "oracle", prompt: "test", mode: "subagent" },
|
||||
})
|
||||
|
||||
spyOn(sisyphusJunior, "createSisyphusJuniorAgentWithOverrides" as any).mockReturnValue({
|
||||
name: "sisyphus-junior",
|
||||
prompt: "test",
|
||||
mode: "subagent",
|
||||
})
|
||||
|
||||
spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({})
|
||||
spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({})
|
||||
spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({})
|
||||
@@ -63,7 +57,7 @@ beforeEach(() => {
|
||||
spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({})
|
||||
|
||||
spyOn(shared, "log" as any).mockImplementation(() => {})
|
||||
spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-5"]))
|
||||
spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-6"]))
|
||||
spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null)
|
||||
|
||||
spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({
|
||||
@@ -73,7 +67,7 @@ beforeEach(() => {
|
||||
|
||||
spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record<string, unknown>) => config)
|
||||
|
||||
spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-5" })
|
||||
spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-6" })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -105,6 +99,66 @@ afterEach(() => {
|
||||
;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.()
|
||||
})
|
||||
|
||||
describe("Sisyphus-Junior model inheritance", () => {
|
||||
test("does not inherit UI-selected model as system default", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "opencode/kimi-k2.5-free",
|
||||
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<string, { model?: string }>
|
||||
expect(agentConfig["sisyphus-junior"]?.model).toBe(
|
||||
sisyphusJunior.SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
)
|
||||
})
|
||||
|
||||
test("uses explicitly configured sisyphus-junior model", async () => {
|
||||
// #given
|
||||
const pluginConfig: OhMyOpenCodeConfig = {
|
||||
agents: {
|
||||
"sisyphus-junior": {
|
||||
model: "openai/gpt-5.3-codex",
|
||||
},
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "opencode/kimi-k2.5-free",
|
||||
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<string, { model?: string }>
|
||||
expect(agentConfig["sisyphus-junior"]?.model).toBe(
|
||||
"openai/gpt-5.3-codex"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Plan agent demote behavior", () => {
|
||||
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
|
||||
// #given
|
||||
@@ -123,7 +177,7 @@ describe("Plan agent demote behavior", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -154,7 +208,7 @@ describe("Plan agent demote behavior", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {
|
||||
plan: {
|
||||
name: "plan",
|
||||
@@ -191,7 +245,7 @@ describe("Plan agent demote behavior", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {
|
||||
plan: {
|
||||
name: "plan",
|
||||
@@ -228,7 +282,7 @@ describe("Plan agent demote behavior", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -263,7 +317,7 @@ describe("Agent permission defaults", () => {
|
||||
})
|
||||
const pluginConfig: OhMyOpenCodeConfig = {}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -295,7 +349,7 @@ describe("Prometheus category config resolution", () => {
|
||||
|
||||
// then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(config?.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(config?.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
@@ -355,7 +409,7 @@ describe("Prometheus category config resolution", () => {
|
||||
|
||||
// then - falls back to DEFAULT_CATEGORIES
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.model).toBe("openai/gpt-5.2-codex")
|
||||
expect(config?.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(config?.variant).toBe("xhigh")
|
||||
})
|
||||
|
||||
@@ -406,7 +460,7 @@ describe("Prometheus direct override priority over category", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -446,7 +500,7 @@ describe("Prometheus direct override priority over category", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -487,7 +541,7 @@ describe("Prometheus direct override priority over category", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -522,7 +576,7 @@ describe("Prometheus direct override priority over category", () => {
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const handler = createConfigHandler({
|
||||
@@ -560,7 +614,7 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", (
|
||||
},
|
||||
}
|
||||
const config: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
agent: {},
|
||||
}
|
||||
const mockClient = {
|
||||
|
||||
@@ -222,7 +222,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
|
||||
agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["sisyphus-junior"],
|
||||
config.model as string | undefined
|
||||
undefined
|
||||
);
|
||||
|
||||
if (builderEnabled) {
|
||||
|
||||
@@ -9,22 +9,3 @@ export function createModelCacheState(): ModelCacheState {
|
||||
anthropicContext1MEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelLimit(
|
||||
state: ModelCacheState,
|
||||
providerID: string,
|
||||
modelID: string
|
||||
): number | undefined {
|
||||
const key = `${providerID}/${modelID}`;
|
||||
const cached = state.modelContextLimitsCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
if (
|
||||
providerID === "anthropic" &&
|
||||
state.anthropicContext1MEnabled &&
|
||||
modelID.includes("sonnet")
|
||||
) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@
|
||||
## STRUCTURE
|
||||
```
|
||||
shared/
|
||||
├── tmux/ # Tmux TUI integration (types, utils, constants)
|
||||
├── tmux/ # Tmux TUI integration (types, utils 312 lines, constants)
|
||||
├── logger.ts # File-based logging (/tmp/oh-my-opencode.log) - 53 imports
|
||||
├── dynamic-truncator.ts # Token-aware context window management (194 lines)
|
||||
├── model-resolver.ts # 3-step resolution (Override → Fallback → Default)
|
||||
├── model-requirements.ts # Agent/category model fallback chains (162 lines)
|
||||
├── model-availability.ts # Provider model fetching & fuzzy matching (154 lines)
|
||||
├── model-availability.ts # Provider model fetching & fuzzy matching (357 lines)
|
||||
├── model-sanitizer.ts # Model name sanitization
|
||||
├── model-suggestion-retry.ts # Model suggestion on failure
|
||||
├── jsonc-parser.ts # JSONC parsing with comment support
|
||||
├── frontmatter.ts # YAML frontmatter extraction (JSON_SCHEMA only) - 9 imports
|
||||
├── data-path.ts # XDG-compliant storage resolution
|
||||
@@ -22,16 +24,26 @@ shared/
|
||||
├── claude-config-dir.ts # ~/.claude resolution - 9 imports
|
||||
├── migration.ts # Legacy config migration logic (231 lines)
|
||||
├── opencode-version.ts # Semantic version comparison
|
||||
├── permission-compat.ts # Agent tool restriction enforcement
|
||||
├── system-directive.ts # Unified system message prefix & types
|
||||
├── permission-compat.ts # Agent tool restriction enforcement - 6 imports
|
||||
├── system-directive.ts # Unified system message prefix & types - 8 imports
|
||||
├── session-utils.ts # Session cursor, orchestrator detection
|
||||
├── session-cursor.ts # Session message cursor tracking
|
||||
├── shell-env.ts # Cross-platform shell environment
|
||||
├── agent-variant.ts # Agent variant from config
|
||||
├── zip-extractor.ts # Binary/Resource ZIP extraction
|
||||
├── deep-merge.ts # Recursive object merging (proto-pollution safe, MAX_DEPTH=50)
|
||||
├── case-insensitive.ts # Case-insensitive object lookups
|
||||
├── session-cursor.ts # Session message cursor tracking
|
||||
├── command-executor.ts # Shell command execution (225 lines)
|
||||
├── snake-case.ts # Case conversion utilities
|
||||
├── tool-name.ts # Tool naming conventions
|
||||
├── pattern-matcher.ts # Pattern matching utilities
|
||||
├── port-utils.ts # Port management
|
||||
├── file-utils.ts # File operation utilities
|
||||
├── file-reference-resolver.ts # File reference resolution
|
||||
├── connected-providers-cache.ts # Provider caching
|
||||
├── external-plugin-detector.ts # Plugin detection
|
||||
├── first-message-variant.ts # Message variant types
|
||||
├── opencode-server-auth.ts # Authentication utilities
|
||||
└── index.ts # Barrel export for all utilities
|
||||
```
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ describe("Agent Config Integration", () => {
|
||||
test("migrates old format agent keys to lowercase", () => {
|
||||
// given - config with old format keys
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
Atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
|
||||
"Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-5" },
|
||||
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" },
|
||||
}
|
||||
@@ -33,9 +33,9 @@ describe("Agent Config Integration", () => {
|
||||
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)")
|
||||
|
||||
// then - values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
|
||||
// then - changed flag is true
|
||||
expect(result.changed).toBe(true)
|
||||
@@ -44,7 +44,7 @@ describe("Agent Config Integration", () => {
|
||||
test("preserves already lowercase keys", () => {
|
||||
// given - config with lowercase keys
|
||||
const config = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
@@ -62,9 +62,9 @@ describe("Agent Config Integration", () => {
|
||||
test("handles mixed case config", () => {
|
||||
// given - config with mixed old and new format
|
||||
const mixedConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
|
||||
librarian: { model: "opencode/glm-4.7-free" },
|
||||
}
|
||||
|
||||
@@ -172,8 +172,8 @@ describe("Agent Config Integration", () => {
|
||||
test("old config migrates and displays correctly", () => {
|
||||
// given - old format config
|
||||
const oldConfig = {
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-5", temperature: 0.1 },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
|
||||
Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 },
|
||||
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
// when - config is migrated
|
||||
@@ -192,15 +192,15 @@ describe("Agent Config Integration", () => {
|
||||
expect(prometheusDisplay).toBe("Prometheus (Plan Builder)")
|
||||
|
||||
// then - config values are preserved
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6", temperature: 0.1 })
|
||||
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
})
|
||||
|
||||
test("new config works without migration", () => {
|
||||
// given - new format config (already lowercase)
|
||||
const newConfig = {
|
||||
sisyphus: { model: "anthropic/claude-opus-4-5" },
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
sisyphus: { model: "anthropic/claude-opus-4-6" },
|
||||
atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
// when - migration is applied (should be no-op)
|
||||
|
||||
@@ -22,6 +22,7 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
edit: false,
|
||||
task: false,
|
||||
delegate_task: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
|
||||
metis: {
|
||||
|
||||
@@ -84,14 +84,14 @@ describe("applyAgentVariant", () => {
|
||||
|
||||
describe("resolveVariantForModel", () => {
|
||||
test("returns agent override variant when configured", () => {
|
||||
// given - use a model in sisyphus chain (claude-opus-4-5 has default variant "max")
|
||||
// given - use a model in sisyphus chain (claude-opus-4-6 has default variant "max")
|
||||
// to verify override takes precedence over fallback chain
|
||||
const config = {
|
||||
agents: {
|
||||
sisyphus: { variant: "high" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
@@ -103,7 +103,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for anthropic provider", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "sisyphus", model)
|
||||
@@ -113,9 +113,9 @@ describe("resolveVariantForModel", () => {
|
||||
})
|
||||
|
||||
test("returns correct variant for openai provider (hephaestus agent)", () => {
|
||||
// #given hephaestus has openai/gpt-5.2-codex with variant "medium" in its chain
|
||||
// #given hephaestus has openai/gpt-5.3-codex with variant "medium" in its chain
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2-codex" }
|
||||
const model = { providerID: "openai", modelID: "gpt-5.3-codex" }
|
||||
|
||||
// #when
|
||||
const variant = resolveVariantForModel(config, "hephaestus", model)
|
||||
@@ -151,7 +151,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns undefined for unknown agent", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "nonexistent-agent", model)
|
||||
@@ -179,7 +179,7 @@ describe("resolveVariantForModel", () => {
|
||||
"custom-agent": { category: "ultrabrain" },
|
||||
},
|
||||
} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "openai", modelID: "gpt-5.2-codex" }
|
||||
const model = { providerID: "openai", modelID: "gpt-5.3-codex" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "custom-agent", model)
|
||||
@@ -203,7 +203,7 @@ describe("resolveVariantForModel", () => {
|
||||
test("returns correct variant for oracle agent with anthropic", () => {
|
||||
// given
|
||||
const config = {} as OhMyOpenCodeConfig
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-5" }
|
||||
const model = { providerID: "anthropic", modelID: "claude-opus-4-6" }
|
||||
|
||||
// when
|
||||
const variant = resolveVariantForModel(config, "oracle", model)
|
||||
|
||||
@@ -11,8 +11,16 @@ interface ConnectedProvidersCache {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface ModelMetadata {
|
||||
id: string
|
||||
provider?: string
|
||||
context?: number
|
||||
output?: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface ProviderModelsCache {
|
||||
models: Record<string, string[]>
|
||||
models: Record<string, string[] | ModelMetadata[]>
|
||||
connected: string[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
19
src/shared/disabled-tools.ts
Normal file
19
src/shared/disabled-tools.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ToolDefinition } from "@opencode-ai/plugin"
|
||||
|
||||
export function filterDisabledTools(
|
||||
tools: Record<string, ToolDefinition>,
|
||||
disabledTools: readonly string[] | undefined
|
||||
): Record<string, ToolDefinition> {
|
||||
if (!disabledTools || disabledTools.length === 0) {
|
||||
return tools
|
||||
}
|
||||
|
||||
const disabledToolSet = new Set(disabledTools)
|
||||
const filtered: Record<string, ToolDefinition> = {}
|
||||
for (const [toolName, toolDefinition] of Object.entries(tools)) {
|
||||
if (!disabledToolSet.has(toolName)) {
|
||||
filtered[toolName] = toolDefinition
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
@@ -40,3 +40,4 @@ export * from "./session-utils"
|
||||
export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
export * from "./opencode-server-auth"
|
||||
export * from "./port-utils"
|
||||
|
||||
@@ -4,8 +4,10 @@ import * as path from "path"
|
||||
import {
|
||||
AGENT_NAME_MAP,
|
||||
HOOK_NAME_MAP,
|
||||
MODEL_VERSION_MAP,
|
||||
migrateAgentNames,
|
||||
migrateHookNames,
|
||||
migrateModelVersions,
|
||||
migrateConfigFile,
|
||||
migrateAgentConfigToCategory,
|
||||
shouldDeleteAgentConfig,
|
||||
@@ -15,7 +17,7 @@ describe("migrateAgentNames", () => {
|
||||
test("migrates legacy OmO names to lowercase", () => {
|
||||
// given: Config with legacy OmO agent names
|
||||
const agents = {
|
||||
omo: { model: "anthropic/claude-opus-4-5" },
|
||||
omo: { model: "anthropic/claude-opus-4-6" },
|
||||
OmO: { temperature: 0.5 },
|
||||
"OmO-Plan": { prompt: "custom prompt" },
|
||||
}
|
||||
@@ -84,7 +86,7 @@ describe("migrateAgentNames", () => {
|
||||
test("migrates orchestrator-sisyphus to atlas", () => {
|
||||
// given: Config with legacy orchestrator-sisyphus agent name
|
||||
const agents = {
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-5" },
|
||||
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
// when: Migrate agent names
|
||||
@@ -92,14 +94,14 @@ describe("migrateAgentNames", () => {
|
||||
|
||||
// then: orchestrator-sisyphus should be migrated to atlas
|
||||
expect(changed).toBe(true)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates lowercase atlas to atlas", () => {
|
||||
// given: Config with lowercase atlas agent name
|
||||
const agents = {
|
||||
atlas: { model: "anthropic/claude-opus-4-5" },
|
||||
atlas: { model: "anthropic/claude-opus-4-6" },
|
||||
}
|
||||
|
||||
// when: Migrate agent names
|
||||
@@ -107,7 +109,7 @@ describe("migrateAgentNames", () => {
|
||||
|
||||
// then: lowercase atlas should remain atlas (no change needed)
|
||||
expect(changed).toBe(false)
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
|
||||
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-6" })
|
||||
})
|
||||
|
||||
test("migrates Sisyphus variants to lowercase", () => {
|
||||
@@ -369,29 +371,81 @@ describe("migrateConfigFile", () => {
|
||||
expect(needsWrite).toBe(false)
|
||||
})
|
||||
|
||||
test("handles migration of all legacy items together", () => {
|
||||
// given: Config with all legacy items
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
"OmO-Plan": { prompt: "custom" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-auto-compact"],
|
||||
}
|
||||
test("handles migration of all legacy items together", () => {
|
||||
// given: Config with all legacy items
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
omo_agent: { disabled: false },
|
||||
agents: {
|
||||
omo: { model: "test" },
|
||||
"OmO-Plan": { prompt: "custom" },
|
||||
},
|
||||
disabled_hooks: ["anthropic-auto-compact"],
|
||||
}
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: All legacy items should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["sisyphus"]).toBeDefined()
|
||||
expect(agents["prometheus"]).toBeDefined()
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
// then: All legacy items should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
|
||||
expect(rawConfig.omo_agent).toBeUndefined()
|
||||
const agents = rawConfig.agents as Record<string, unknown>
|
||||
expect(agents["sisyphus"]).toBeDefined()
|
||||
expect(agents["prometheus"]).toBeDefined()
|
||||
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
|
||||
})
|
||||
|
||||
test("migrates model versions in agents", () => {
|
||||
// given: Config with old model version in agents
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "openai/gpt-5.2-codex", temperature: 0.1 },
|
||||
},
|
||||
}
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: Model version should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
expect(agents["sisyphus"].model).toBe("openai/gpt-5.3-codex")
|
||||
})
|
||||
|
||||
test("migrates model versions in categories", () => {
|
||||
// given: Config with old model version in categories
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
categories: {
|
||||
"my-category": { model: "anthropic/claude-opus-4-5", temperature: 0.2 },
|
||||
},
|
||||
}
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: Model version should be migrated
|
||||
expect(needsWrite).toBe(true)
|
||||
const categories = rawConfig.categories as Record<string, Record<string, unknown>>
|
||||
expect(categories["my-category"].model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("does not set needsWrite when no model versions need migration", () => {
|
||||
// given: Config with current model versions
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
sisyphus: { model: "openai/gpt-5.3-codex" },
|
||||
},
|
||||
categories: {
|
||||
"my-category": { model: "anthropic/claude-opus-4-6" },
|
||||
},
|
||||
}
|
||||
|
||||
// when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// then: No write should be needed
|
||||
expect(needsWrite).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("migration maps", () => {
|
||||
@@ -413,6 +467,126 @@ describe("migration maps", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("MODEL_VERSION_MAP", () => {
|
||||
test("maps openai/gpt-5.2-codex to openai/gpt-5.3-codex", () => {
|
||||
// given/when: Check MODEL_VERSION_MAP
|
||||
// then: Should contain correct mapping
|
||||
expect(MODEL_VERSION_MAP["openai/gpt-5.2-codex"]).toBe("openai/gpt-5.3-codex")
|
||||
})
|
||||
|
||||
test("maps anthropic/claude-opus-4-5 to anthropic/claude-opus-4-6", () => {
|
||||
// given/when: Check MODEL_VERSION_MAP
|
||||
// then: Should contain correct mapping
|
||||
expect(MODEL_VERSION_MAP["anthropic/claude-opus-4-5"]).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateModelVersions", () => {
|
||||
test("replaces old model string in agent config", () => {
|
||||
// given: Agent config with old model version
|
||||
const agents = {
|
||||
sisyphus: { model: "openai/gpt-5.2-codex", temperature: 0.1 },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Model should be updated, other fields preserved
|
||||
expect(changed).toBe(true)
|
||||
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
||||
expect(sisyphus.model).toBe("openai/gpt-5.3-codex")
|
||||
expect(sisyphus.temperature).toBe(0.1)
|
||||
})
|
||||
|
||||
test("replaces anthropic model version", () => {
|
||||
// given: Agent config with old anthropic model
|
||||
const agents = {
|
||||
prometheus: { model: "anthropic/claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Model should be updated
|
||||
expect(changed).toBe(true)
|
||||
const prometheus = migrated["prometheus"] as Record<string, unknown>
|
||||
expect(prometheus.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("leaves unknown model strings untouched", () => {
|
||||
// given: Agent config with unknown model
|
||||
const agents = {
|
||||
oracle: { model: "openai/gpt-5.2", temperature: 0.5 },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Config should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
const oracle = migrated["oracle"] as Record<string, unknown>
|
||||
expect(oracle.model).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("handles agent config with no model field", () => {
|
||||
// given: Agent config without model field
|
||||
const agents = {
|
||||
sisyphus: { temperature: 0.1, prompt: "custom" },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Config should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
const sisyphus = migrated["sisyphus"] as Record<string, unknown>
|
||||
expect(sisyphus.temperature).toBe(0.1)
|
||||
})
|
||||
|
||||
test("handles agent config with non-string model", () => {
|
||||
// given: Agent config with non-string model
|
||||
const agents = {
|
||||
sisyphus: { model: 123, temperature: 0.1 },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Config should remain unchanged
|
||||
expect(changed).toBe(false)
|
||||
})
|
||||
|
||||
test("migrates multiple agents in one pass", () => {
|
||||
// given: Multiple agents with old models
|
||||
const agents = {
|
||||
sisyphus: { model: "openai/gpt-5.2-codex" },
|
||||
prometheus: { model: "anthropic/claude-opus-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Only mapped models should be updated
|
||||
expect(changed).toBe(true)
|
||||
expect((migrated["sisyphus"] as Record<string, unknown>).model).toBe("openai/gpt-5.3-codex")
|
||||
expect((migrated["prometheus"] as Record<string, unknown>).model).toBe("anthropic/claude-opus-4-6")
|
||||
expect((migrated["oracle"] as Record<string, unknown>).model).toBe("openai/gpt-5.2")
|
||||
})
|
||||
|
||||
test("handles empty object", () => {
|
||||
// given: Empty agents object
|
||||
const agents = {}
|
||||
|
||||
// when: Migrate model versions
|
||||
const { migrated, changed } = migrateModelVersions(agents)
|
||||
|
||||
// then: Should return empty with no change
|
||||
expect(changed).toBe(false)
|
||||
expect(Object.keys(migrated)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateAgentConfigToCategory", () => {
|
||||
test("migrates model to category when mapping exists", () => {
|
||||
// given: Config with a model that has a category mapping
|
||||
@@ -470,7 +644,7 @@ describe("migrateAgentConfigToCategory", () => {
|
||||
{ model: "google/gemini-3-flash" },
|
||||
{ model: "openai/gpt-5.2" },
|
||||
{ model: "anthropic/claude-haiku-4-5" },
|
||||
{ model: "anthropic/claude-opus-4-5" },
|
||||
{ model: "anthropic/claude-opus-4-6" },
|
||||
{ model: "anthropic/claude-sonnet-4-5" },
|
||||
]
|
||||
|
||||
@@ -550,7 +724,7 @@ describe("shouldDeleteAgentConfig", () => {
|
||||
// given: Config with custom model override
|
||||
const config = {
|
||||
category: "visual-engineering",
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
model: "anthropic/claude-opus-4-6",
|
||||
}
|
||||
|
||||
// when: Check if config should be deleted
|
||||
|
||||
@@ -85,10 +85,22 @@ export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
|
||||
"google/gemini-3-flash": "writing",
|
||||
"openai/gpt-5.2": "ultrabrain",
|
||||
"anthropic/claude-haiku-4-5": "quick",
|
||||
"anthropic/claude-opus-4-5": "unspecified-high",
|
||||
"anthropic/claude-opus-4-6": "unspecified-high",
|
||||
"anthropic/claude-sonnet-4-5": "unspecified-low",
|
||||
}
|
||||
|
||||
/**
|
||||
* Model version migration map: old full model strings → new full model strings.
|
||||
* Used to auto-upgrade hardcoded model versions in user configs when the plugin
|
||||
* bumps to newer model versions.
|
||||
*
|
||||
* Keys are full "provider/model" strings. Only openai and anthropic entries needed.
|
||||
*/
|
||||
export const MODEL_VERSION_MAP: Record<string, string> = {
|
||||
"openai/gpt-5.2-codex": "openai/gpt-5.3-codex",
|
||||
"anthropic/claude-opus-4-5": "anthropic/claude-opus-4-6",
|
||||
}
|
||||
|
||||
export function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
|
||||
const migrated: Record<string, unknown> = {}
|
||||
let changed = false
|
||||
@@ -104,6 +116,25 @@ export function migrateAgentNames(agents: Record<string, unknown>): { migrated:
|
||||
return { migrated, changed }
|
||||
}
|
||||
|
||||
export function migrateModelVersions(configs: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
|
||||
const migrated: Record<string, unknown> = {}
|
||||
let changed = false
|
||||
|
||||
for (const [key, value] of Object.entries(configs)) {
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
const config = value as Record<string, unknown>
|
||||
if (typeof config.model === "string" && MODEL_VERSION_MAP[config.model]) {
|
||||
migrated[key] = { ...config, model: MODEL_VERSION_MAP[config.model] }
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
migrated[key] = value
|
||||
}
|
||||
|
||||
return { migrated, changed }
|
||||
}
|
||||
|
||||
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean; removed: string[] } {
|
||||
const migrated: string[] = []
|
||||
const removed: string[] = []
|
||||
@@ -178,7 +209,25 @@ export function migrateConfigFile(configPath: string, rawConfig: Record<string,
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate model versions in agents
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const { migrated, changed } = migrateModelVersions(rawConfig.agents as Record<string, unknown>)
|
||||
if (changed) {
|
||||
rawConfig.agents = migrated
|
||||
needsWrite = true
|
||||
log(`Migrated model versions in agents config`)
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate model versions in categories
|
||||
if (rawConfig.categories && typeof rawConfig.categories === "object") {
|
||||
const { migrated, changed } = migrateModelVersions(rawConfig.categories as Record<string, unknown>)
|
||||
if (changed) {
|
||||
rawConfig.categories = migrated
|
||||
needsWrite = true
|
||||
log(`Migrated model versions in categories config`)
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent
|
||||
|
||||
@@ -33,7 +33,7 @@ describe("fetchAvailableModels", () => {
|
||||
it("#given cache file with models #when fetchAvailableModels called with connectedProviders #then returns Set of model IDs", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
|
||||
@@ -44,7 +44,7 @@ describe("fetchAvailableModels", () => {
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("fetchAvailableModels", () => {
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.2-codex", provider: "openai" },
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
@@ -77,7 +77,7 @@ describe("fetchAvailableModels", () => {
|
||||
const result = await fetchAvailableModels(client)
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("fetchAvailableModels", () => {
|
||||
model: {
|
||||
list: async () => ({
|
||||
data: [
|
||||
{ id: "gpt-5.2-codex", provider: "openai" },
|
||||
{ id: "gpt-5.3-codex", provider: "openai" },
|
||||
{ id: "gemini-3-pro", provider: "google" },
|
||||
],
|
||||
}),
|
||||
@@ -106,14 +106,14 @@ describe("fetchAvailableModels", () => {
|
||||
const result = await fetchAvailableModels(client, { connectedProviders: ["openai", "google"] })
|
||||
|
||||
expect(result).toBeInstanceOf(Set)
|
||||
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
})
|
||||
|
||||
it("#given cache read twice #when second call made with same providers #then reads fresh each time", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
const result1 = await fetchAvailableModels(undefined, { connectedProviders: ["openai"] })
|
||||
@@ -134,7 +134,7 @@ describe("fetchAvailableModels", () => {
|
||||
|
||||
it("#given cache file with various providers #when fetchAvailableModels called with all providers #then extracts all IDs correctly", async () => {
|
||||
writeModelsCache({
|
||||
openai: { id: "openai", models: { "gpt-5.2-codex": { id: "gpt-5.2-codex" } } },
|
||||
openai: { id: "openai", models: { "gpt-5.3-codex": { id: "gpt-5.3-codex" } } },
|
||||
anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } },
|
||||
google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } },
|
||||
opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } },
|
||||
@@ -145,7 +145,7 @@ describe("fetchAvailableModels", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.3-codex")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
|
||||
expect(result.has("google/gemini-3-flash")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5-nano")).toBe(true)
|
||||
@@ -159,8 +159,8 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should match substring in model name", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"openai/gpt-5.2-codex",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"openai/gpt-5.3-codex",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt-5.2", available)
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
@@ -185,7 +185,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should prefer exact match over substring match", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"openai/gpt-5.2-codex",
|
||||
"openai/gpt-5.3-codex",
|
||||
"openai/gpt-5.2-ultra",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt-5.2", available)
|
||||
@@ -207,13 +207,13 @@ describe("fuzzyMatchModel", () => {
|
||||
// given available models with claude variants
|
||||
// when searching for claude-opus
|
||||
// then return matching claude-opus model
|
||||
it("should match claude-opus to claude-opus-4-5", () => {
|
||||
it("should match claude-opus to claude-opus-4-6", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude-opus", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
// given available models from multiple providers
|
||||
@@ -222,7 +222,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should filter by provider when providers array is given", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"google/gemini-3",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt", available, ["openai"])
|
||||
@@ -235,7 +235,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should return null when provider filter excludes all matches", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude", available, ["openai"])
|
||||
expect(result).toBeNull()
|
||||
@@ -247,7 +247,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should return null when no match found", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
const result = fuzzyMatchModel("gemini", available)
|
||||
expect(result).toBeNull()
|
||||
@@ -259,7 +259,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should match case-insensitively", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
const result = fuzzyMatchModel("GPT-5.2", available)
|
||||
expect(result).toBe("openai/gpt-5.2")
|
||||
@@ -270,11 +270,11 @@ describe("fuzzyMatchModel", () => {
|
||||
// then return exact match first
|
||||
it("should prioritize exact match over longer variants", () => {
|
||||
const available = new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-5-extended",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"anthropic/claude-opus-4-6-extended",
|
||||
])
|
||||
const result = fuzzyMatchModel("claude-opus-4-5", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
const result = fuzzyMatchModel("claude-opus-4-6", available)
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
// given available models with similar model IDs (e.g., glm-4.7 and glm-4.7-free)
|
||||
@@ -319,7 +319,7 @@ describe("fuzzyMatchModel", () => {
|
||||
it("should search all specified providers", () => {
|
||||
const available = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
"google/gemini-3",
|
||||
])
|
||||
const result = fuzzyMatchModel("gpt", available, ["openai", "google"])
|
||||
@@ -464,7 +464,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should filter models by connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
|
||||
@@ -473,7 +473,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
@@ -484,7 +484,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should filter models by multiple connected providers", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
google: { models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
|
||||
})
|
||||
|
||||
@@ -493,7 +493,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(true)
|
||||
expect(result.has("openai/gpt-5.2")).toBe(false)
|
||||
})
|
||||
@@ -504,7 +504,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return empty set when connectedProviders is empty", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -520,7 +520,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return empty set when connectedProviders not specified", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels()
|
||||
@@ -549,7 +549,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should return models from providers that exist in both cache and connected list", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -557,7 +557,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
})
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
})
|
||||
|
||||
// given filtered fetch
|
||||
@@ -566,7 +566,7 @@ describe("fetchAvailableModels with connected providers filtering", () => {
|
||||
it("should not cache filtered results", async () => {
|
||||
writeModelsCache({
|
||||
openai: { models: { "gpt-5.2": { id: "gpt-5.2" } } },
|
||||
anthropic: { models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
|
||||
anthropic: { models: { "claude-opus-4-6": { id: "claude-opus-4-6" } } },
|
||||
})
|
||||
|
||||
// First call with anthropic
|
||||
@@ -619,7 +619,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[]>; connected: string[] }) {
|
||||
function writeProviderModelsCache(data: { models: Record<string, string[] | any[]>; connected: string[] }) {
|
||||
const cacheDir = join(tempDir, "oh-my-opencode")
|
||||
require("fs").mkdirSync(cacheDir, { recursive: true })
|
||||
writeFileSync(join(cacheDir, "provider-models.json"), JSON.stringify({
|
||||
@@ -641,13 +641,13 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["glm-4.7-free", "gpt-5-nano"],
|
||||
anthropic: ["claude-opus-4-5"]
|
||||
anthropic: ["claude-opus-4-6"]
|
||||
},
|
||||
connected: ["opencode", "anthropic"]
|
||||
})
|
||||
writeModelsCache({
|
||||
opencode: { models: { "glm-4.7-free": {}, "gpt-5-nano": {}, "gpt-5.2": {} } },
|
||||
anthropic: { models: { "claude-opus-4-5": {}, "claude-sonnet-4-5": {} } }
|
||||
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-5": {} } }
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
@@ -657,7 +657,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
expect(result.size).toBe(3)
|
||||
expect(result.has("opencode/glm-4.7-free")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5-nano")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("opencode/gpt-5.2")).toBe(false)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(false)
|
||||
})
|
||||
@@ -708,7 +708,7 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
opencode: ["glm-4.7-free"],
|
||||
anthropic: ["claude-opus-4-5"],
|
||||
anthropic: ["claude-opus-4-6"],
|
||||
google: ["gemini-3-pro"]
|
||||
},
|
||||
connected: ["opencode", "anthropic", "google"]
|
||||
@@ -720,18 +720,84 @@ describe("fetchAvailableModels with provider-models cache (whitelist-filtered)",
|
||||
|
||||
expect(result.size).toBe(1)
|
||||
expect(result.has("opencode/glm-4.7-free")).toBe(true)
|
||||
expect(result.has("anthropic/claude-opus-4-5")).toBe(false)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(false)
|
||||
expect(result.has("google/gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle object[] format with metadata (Ollama-style)", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
ollama: [
|
||||
{ id: "ministral-3:14b-32k-agent", provider: "ollama", context: 32768, output: 8192 },
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama", context: 32768, output: 8192 }
|
||||
]
|
||||
},
|
||||
connected: ["ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has("ollama/ministral-3:14b-32k-agent")).toBe(true)
|
||||
expect(result.has("ollama/qwen3-coder:32k-agent")).toBe(true)
|
||||
})
|
||||
|
||||
it("should handle mixed string[] and object[] formats across providers", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
anthropic: ["claude-opus-4-6", "claude-sonnet-4-5"],
|
||||
ollama: [
|
||||
{ id: "ministral-3:14b-32k-agent", provider: "ollama" },
|
||||
{ id: "qwen3-coder:32k-agent", provider: "ollama" }
|
||||
]
|
||||
},
|
||||
connected: ["anthropic", "ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["anthropic", "ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(4)
|
||||
expect(result.has("anthropic/claude-opus-4-6")).toBe(true)
|
||||
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
|
||||
expect(result.has("ollama/ministral-3:14b-32k-agent")).toBe(true)
|
||||
expect(result.has("ollama/qwen3-coder:32k-agent")).toBe(true)
|
||||
})
|
||||
|
||||
it("should skip invalid entries in object[] format", async () => {
|
||||
writeProviderModelsCache({
|
||||
models: {
|
||||
ollama: [
|
||||
{ id: "valid-model", provider: "ollama" },
|
||||
{ provider: "ollama" },
|
||||
{ id: "", provider: "ollama" },
|
||||
null,
|
||||
"string-model"
|
||||
]
|
||||
},
|
||||
connected: ["ollama"]
|
||||
})
|
||||
|
||||
const result = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: ["ollama"]
|
||||
})
|
||||
|
||||
expect(result.size).toBe(2)
|
||||
expect(result.has("ollama/valid-model")).toBe(true)
|
||||
expect(result.has("ollama/string-model")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isModelAvailable", () => {
|
||||
it("returns true when model exists via fuzzy match", () => {
|
||||
// given
|
||||
const available = new Set(["openai/gpt-5.2-codex", "anthropic/claude-opus-4-5"])
|
||||
const available = new Set(["openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
|
||||
// then
|
||||
expect(result).toBe(true)
|
||||
@@ -739,10 +805,10 @@ describe("isModelAvailable", () => {
|
||||
|
||||
it("returns false when model not found", () => {
|
||||
// given
|
||||
const available = new Set(["anthropic/claude-opus-4-5"])
|
||||
const available = new Set(["anthropic/claude-opus-4-6"])
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
@@ -753,7 +819,7 @@ describe("isModelAvailable", () => {
|
||||
const available = new Set<string>()
|
||||
|
||||
// when
|
||||
const result = isModelAvailable("gpt-5.2-codex", available)
|
||||
const result = isModelAvailable("gpt-5.3-codex", available)
|
||||
|
||||
// then
|
||||
expect(result).toBe(false)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { log } from "./logger"
|
||||
import { getOpenCodeCacheDir } from "./data-path"
|
||||
import { readProviderModelsCache, hasProviderModelsCache } from "./connected-providers-cache"
|
||||
import { readProviderModelsCache, hasProviderModelsCache, readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
|
||||
/**
|
||||
* Fuzzy match a target model name against available models
|
||||
@@ -20,7 +20,7 @@ import { readProviderModelsCache, hasProviderModelsCache } from "./connected-pro
|
||||
* If providers array is given, only models starting with "provider/" are considered.
|
||||
*
|
||||
* @example
|
||||
* const available = new Set(["openai/gpt-5.2", "openai/gpt-5.2-codex", "anthropic/claude-opus-4-5"])
|
||||
* const available = new Set(["openai/gpt-5.2", "openai/gpt-5.3-codex", "anthropic/claude-opus-4-6"])
|
||||
* fuzzyMatchModel("gpt-5.2", available) // → "openai/gpt-5.2"
|
||||
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
|
||||
*/
|
||||
@@ -105,7 +105,7 @@ export function fuzzyMatchModel(
|
||||
/**
|
||||
* Check if a target model is available (fuzzy match by model name, no provider filtering)
|
||||
*
|
||||
* @param targetModel - Model name to check (e.g., "gpt-5.2-codex")
|
||||
* @param targetModel - Model name to check (e.g., "gpt-5.3-codex")
|
||||
* @param availableModels - Set of available models in "provider/model" format
|
||||
* @returns true if model is available, false otherwise
|
||||
*/
|
||||
@@ -187,16 +187,23 @@ export async function fetchAvailableModels(
|
||||
if (providerCount === 0) {
|
||||
log("[fetchAvailableModels] provider-models cache empty, falling back to models.json")
|
||||
} else {
|
||||
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
|
||||
|
||||
for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) {
|
||||
if (!connectedSet.has(providerId)) {
|
||||
continue
|
||||
}
|
||||
for (const modelId of modelIds) {
|
||||
log("[fetchAvailableModels] using provider-models cache (whitelist-filtered)")
|
||||
|
||||
for (const [providerId, modelIds] of Object.entries(providerModelsCache.models)) {
|
||||
if (!connectedSet.has(providerId)) {
|
||||
continue
|
||||
}
|
||||
for (const modelItem of modelIds) {
|
||||
// Handle both string[] (legacy) and object[] (with metadata) formats
|
||||
const modelId = typeof modelItem === 'string'
|
||||
? modelItem
|
||||
: (modelItem as any)?.id
|
||||
|
||||
if (modelId) {
|
||||
modelSet.add(`${providerId}/${modelId}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log("[fetchAvailableModels] parsed from provider-models cache", {
|
||||
count: modelSet.size,
|
||||
@@ -278,19 +285,64 @@ export function isAnyFallbackModelAvailable(
|
||||
fallbackChain: Array<{ providers: string[]; model: string }>,
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
if (availableModels.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const entry of fallbackChain) {
|
||||
const hasAvailableProvider = entry.providers.some((provider) => {
|
||||
return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null
|
||||
})
|
||||
if (hasAvailableProvider) {
|
||||
return true
|
||||
// If we have models, check them first
|
||||
if (availableModels.size > 0) {
|
||||
for (const entry of fallbackChain) {
|
||||
const hasAvailableProvider = entry.providers.some((provider) => {
|
||||
return fuzzyMatchModel(entry.model, availableModels, [provider]) !== null
|
||||
})
|
||||
if (hasAvailableProvider) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
log("[isAnyFallbackModelAvailable] no model available in chain", { chainLength: fallbackChain.length })
|
||||
|
||||
// Fallback: check if any provider in the chain is connected
|
||||
// This handles race conditions where availableModels is empty or incomplete
|
||||
// but we know the provider is connected.
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const entry of fallbackChain) {
|
||||
if (entry.providers.some((p) => connectedSet.has(p))) {
|
||||
log("[isAnyFallbackModelAvailable] model not in available set, but provider is connected", {
|
||||
model: entry.model,
|
||||
availableCount: availableModels.size,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function isAnyProviderConnected(
|
||||
providers: string[],
|
||||
availableModels: Set<string>,
|
||||
): boolean {
|
||||
if (availableModels.size > 0) {
|
||||
const providerSet = new Set(providers)
|
||||
for (const model of availableModels) {
|
||||
const [provider] = model.split("/")
|
||||
if (providerSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] found model from required provider", { provider, model })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
if (connectedProviders) {
|
||||
const connectedSet = new Set(connectedProviders)
|
||||
for (const provider of providers) {
|
||||
if (connectedSet.has(provider)) {
|
||||
log("[isAnyProviderConnected] provider connected via cache", { provider })
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -23,20 +23,20 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.variant).toBe("high")
|
||||
})
|
||||
|
||||
test("sisyphus has valid fallbackChain with claude-opus-4-5 as primary and requiresAnyModel", () => {
|
||||
test("sisyphus has claude-opus-4-6 as primary and requiresAnyModel", () => {
|
||||
// #given - sisyphus agent requirement
|
||||
const sisyphus = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
|
||||
// #when - accessing Sisyphus requirement
|
||||
// #then - fallbackChain exists with claude-opus-4-5 as first entry, glm-4.7-free as last
|
||||
// #then - fallbackChain has claude-opus-4-6 first, glm-4.7-free last
|
||||
expect(sisyphus).toBeDefined()
|
||||
expect(sisyphus.fallbackChain).toBeArray()
|
||||
expect(sisyphus.fallbackChain).toHaveLength(5)
|
||||
expect(sisyphus.requiresAnyModel).toBe(true)
|
||||
|
||||
const primary = sisyphus.fallbackChain[0]
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
expect(primary.model).toBe("claude-opus-4-5")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.variant).toBe("max")
|
||||
|
||||
const last = sisyphus.fallbackChain[4]
|
||||
@@ -98,35 +98,35 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.model).toBe("gemini-3-flash")
|
||||
})
|
||||
|
||||
test("prometheus has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// given - prometheus agent requirement
|
||||
test("prometheus has claude-opus-4-6 as primary", () => {
|
||||
// #given - prometheus agent requirement
|
||||
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
|
||||
|
||||
// when - accessing Prometheus requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// #when - accessing Prometheus requirement
|
||||
// #then - claude-opus-4-6 is first
|
||||
expect(prometheus).toBeDefined()
|
||||
expect(prometheus.fallbackChain).toBeArray()
|
||||
expect(prometheus.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(prometheus.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = prometheus.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-5")
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.variant).toBe("max")
|
||||
})
|
||||
|
||||
test("metis has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// given - metis agent requirement
|
||||
test("metis has claude-opus-4-6 as primary", () => {
|
||||
// #given - metis agent requirement
|
||||
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
|
||||
|
||||
// when - accessing Metis requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// #when - accessing Metis requirement
|
||||
// #then - claude-opus-4-6 is first
|
||||
expect(metis).toBeDefined()
|
||||
expect(metis.fallbackChain).toBeArray()
|
||||
expect(metis.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(metis.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = metis.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-5")
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(primary.variant).toBe("max")
|
||||
})
|
||||
|
||||
@@ -161,14 +161,15 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("kimi-for-coding")
|
||||
})
|
||||
|
||||
test("hephaestus requires gpt-5.2-codex", () => {
|
||||
test("hephaestus requires openai/github-copilot/opencode provider", () => {
|
||||
// #given - hephaestus agent requirement
|
||||
const hephaestus = AGENT_MODEL_REQUIREMENTS["hephaestus"]
|
||||
|
||||
// #when - accessing hephaestus requirement
|
||||
// #then - requiresModel is set to gpt-5.2-codex
|
||||
// #then - requiresProvider is set to openai, github-copilot, opencode (not requiresModel)
|
||||
expect(hephaestus).toBeDefined()
|
||||
expect(hephaestus.requiresModel).toBe("gpt-5.2-codex")
|
||||
expect(hephaestus.requiresProvider).toEqual(["openai", "github-copilot", "opencode"])
|
||||
expect(hephaestus.requiresModel).toBeUndefined()
|
||||
})
|
||||
|
||||
test("all 10 builtin agents have valid fallbackChain arrays", () => {
|
||||
@@ -208,35 +209,35 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
|
||||
})
|
||||
|
||||
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
test("ultrabrain has valid fallbackChain with gpt-5.2-codex as primary", () => {
|
||||
test("ultrabrain has valid fallbackChain with gpt-5.3-codex as primary", () => {
|
||||
// given - ultrabrain category requirement
|
||||
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
|
||||
|
||||
// when - accessing ultrabrain requirement
|
||||
// then - fallbackChain exists with gpt-5.2-codex as first entry
|
||||
// then - fallbackChain exists with gpt-5.3-codex as first entry
|
||||
expect(ultrabrain).toBeDefined()
|
||||
expect(ultrabrain.fallbackChain).toBeArray()
|
||||
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
|
||||
|
||||
const primary = ultrabrain.fallbackChain[0]
|
||||
expect(primary.variant).toBe("xhigh")
|
||||
expect(primary.model).toBe("gpt-5.2-codex")
|
||||
expect(primary.model).toBe("gpt-5.3-codex")
|
||||
expect(primary.providers[0]).toBe("openai")
|
||||
})
|
||||
|
||||
test("deep has valid fallbackChain with gpt-5.2-codex as primary", () => {
|
||||
test("deep has valid fallbackChain with gpt-5.3-codex as primary", () => {
|
||||
// given - deep category requirement
|
||||
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
|
||||
|
||||
// when - accessing deep requirement
|
||||
// then - fallbackChain exists with gpt-5.2-codex as first entry, medium variant
|
||||
// then - fallbackChain exists with gpt-5.3-codex as first entry, medium variant
|
||||
expect(deep).toBeDefined()
|
||||
expect(deep.fallbackChain).toBeArray()
|
||||
expect(deep.fallbackChain.length).toBeGreaterThan(0)
|
||||
|
||||
const primary = deep.fallbackChain[0]
|
||||
expect(primary.variant).toBe("medium")
|
||||
expect(primary.model).toBe("gpt-5.2-codex")
|
||||
expect(primary.model).toBe("gpt-5.3-codex")
|
||||
expect(primary.providers[0]).toBe("openai")
|
||||
})
|
||||
|
||||
@@ -285,20 +286,20 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
})
|
||||
|
||||
test("unspecified-high has valid fallbackChain with claude-opus-4-5 as primary", () => {
|
||||
// given - unspecified-high category requirement
|
||||
test("unspecified-high has claude-opus-4-6 as primary", () => {
|
||||
// #given - unspecified-high category requirement
|
||||
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
|
||||
|
||||
// when - accessing unspecified-high requirement
|
||||
// then - fallbackChain exists with claude-opus-4-5 as first entry
|
||||
// #when - accessing unspecified-high requirement
|
||||
// #then - claude-opus-4-6 is first
|
||||
expect(unspecifiedHigh).toBeDefined()
|
||||
expect(unspecifiedHigh.fallbackChain).toBeArray()
|
||||
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(0)
|
||||
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(1)
|
||||
|
||||
const primary = unspecifiedHigh.fallbackChain[0]
|
||||
expect(primary.model).toBe("claude-opus-4-5")
|
||||
expect(primary.model).toBe("claude-opus-4-6")
|
||||
expect(primary.variant).toBe("max")
|
||||
expect(primary.providers[0]).toBe("anthropic")
|
||||
expect(primary.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
})
|
||||
|
||||
test("artistry has valid fallbackChain with gemini-3-pro as primary", () => {
|
||||
@@ -371,14 +372,14 @@ describe("FallbackEntry type", () => {
|
||||
// given - a valid FallbackEntry object
|
||||
const entry: FallbackEntry = {
|
||||
providers: ["anthropic", "github-copilot", "opencode"],
|
||||
model: "claude-opus-4-5",
|
||||
model: "claude-opus-4-6",
|
||||
variant: "high",
|
||||
}
|
||||
|
||||
// when - accessing properties
|
||||
// then - all properties are accessible
|
||||
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
|
||||
expect(entry.model).toBe("claude-opus-4-5")
|
||||
expect(entry.model).toBe("claude-opus-4-6")
|
||||
expect(entry.variant).toBe("high")
|
||||
})
|
||||
|
||||
@@ -400,7 +401,7 @@ describe("ModelRequirement type", () => {
|
||||
// given - a valid ModelRequirement object
|
||||
const requirement: ModelRequirement = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot"], model: "gpt-5.2", variant: "high" },
|
||||
],
|
||||
}
|
||||
@@ -409,7 +410,7 @@ describe("ModelRequirement type", () => {
|
||||
// then - fallbackChain is accessible with correct structure
|
||||
expect(requirement.fallbackChain).toBeArray()
|
||||
expect(requirement.fallbackChain).toHaveLength(2)
|
||||
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-5")
|
||||
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-6")
|
||||
expect(requirement.fallbackChain[1].model).toBe("gpt-5.2")
|
||||
})
|
||||
|
||||
@@ -459,12 +460,12 @@ describe("ModelRequirement type", () => {
|
||||
})
|
||||
|
||||
describe("requiresModel field in categories", () => {
|
||||
test("deep category has requiresModel set to gpt-5.2-codex", () => {
|
||||
test("deep category has requiresModel set to gpt-5.3-codex", () => {
|
||||
// given
|
||||
const deep = CATEGORY_MODEL_REQUIREMENTS["deep"]
|
||||
|
||||
// when / #then
|
||||
expect(deep.requiresModel).toBe("gpt-5.2-codex")
|
||||
expect(deep.requiresModel).toBe("gpt-5.3-codex")
|
||||
})
|
||||
|
||||
test("artistry category has requiresModel set to gemini-3-pro", () => {
|
||||
|
||||
@@ -9,12 +9,13 @@ export type ModelRequirement = {
|
||||
variant?: string // Default variant (used when entry doesn't specify one)
|
||||
requiresModel?: string // If set, only activates when this model is available (fuzzy match)
|
||||
requiresAnyModel?: boolean // If true, requires at least ONE model in fallbackChain to be available (or empty availability treated as unavailable)
|
||||
requiresProvider?: string[] // If set, only activates when any of these providers is connected
|
||||
}
|
||||
|
||||
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
sisyphus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
@@ -24,15 +25,15 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
hephaestus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||
],
|
||||
requiresModel: "gpt-5.2-codex",
|
||||
requiresProvider: ["openai", "github-copilot", "opencode"],
|
||||
},
|
||||
oracle: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
librarian: {
|
||||
@@ -62,7 +63,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
prometheus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
@@ -71,7 +72,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
},
|
||||
metis: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["kimi-for-coding"], model: "k2p5" },
|
||||
{ providers: ["opencode"], model: "kimi-k2.5-free" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
@@ -81,7 +82,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
momus: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
],
|
||||
},
|
||||
@@ -100,33 +101,33 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"visual-engineering": {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
|
||||
],
|
||||
},
|
||||
ultrabrain: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "xhigh" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "xhigh" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
},
|
||||
deep: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
],
|
||||
requiresModel: "gpt-5.2-codex",
|
||||
},
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
requiresModel: "gemini-3-pro",
|
||||
},
|
||||
deep: {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
],
|
||||
requiresModel: "gpt-5.3-codex",
|
||||
},
|
||||
artistry: {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
|
||||
],
|
||||
requiresModel: "gemini-3-pro",
|
||||
},
|
||||
quick: {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
|
||||
@@ -137,13 +138,13 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
|
||||
"unspecified-low": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.3-codex", variant: "medium" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
|
||||
],
|
||||
},
|
||||
"unspecified-high": {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
],
|
||||
|
||||
@@ -8,7 +8,7 @@ describe("resolveModel", () => {
|
||||
test("returns userModel when all three are set", () => {
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
@@ -17,7 +17,7 @@ describe("resolveModel", () => {
|
||||
const result = resolveModel(input)
|
||||
|
||||
// then
|
||||
expect(result).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
|
||||
test("returns inheritedModel when userModel is undefined", () => {
|
||||
@@ -87,7 +87,7 @@ describe("resolveModel", () => {
|
||||
test("same input returns same output (referential transparency)", () => {
|
||||
// given
|
||||
const input: ModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
inheritedModel: "openai/gpt-5.2",
|
||||
systemDefault: "google/gemini-3-pro",
|
||||
}
|
||||
@@ -118,11 +118,11 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/glm-4.7-free",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "opencode/glm-4.7-free",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -156,8 +156,8 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: " ",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -165,16 +165,16 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
||||
})
|
||||
|
||||
test("empty string uiSelectedModel falls through to config override", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
uiSelectedModel: "",
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -190,11 +190,11 @@ describe("resolveModelWithFallback", () => {
|
||||
test("returns userModel with override source when userModel is provided", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -202,9 +202,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("override")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-5" })
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via config override", { model: "anthropic/claude-opus-4-6" })
|
||||
})
|
||||
|
||||
test("override takes priority even if model not in availableModels", () => {
|
||||
@@ -212,9 +212,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "custom/my-model",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -231,9 +231,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: " ",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -249,9 +249,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -268,9 +268,9 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["github-copilot/claude-opus-4-5-preview", "opencode/claude-opus-4-7"]),
|
||||
availableModels: new Set(["github-copilot/claude-opus-4-6-preview", "opencode/claude-opus-4-7"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -278,12 +278,12 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-5-preview")
|
||||
expect(result!.model).toBe("github-copilot/claude-opus-4-6-preview")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
|
||||
provider: "github-copilot",
|
||||
model: "claude-opus-4-5",
|
||||
match: "github-copilot/claude-opus-4-5-preview",
|
||||
model: "claude-opus-4-6",
|
||||
match: "github-copilot/claude-opus-4-6-preview",
|
||||
variant: undefined,
|
||||
})
|
||||
})
|
||||
@@ -294,7 +294,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-5", "google/gemini-3-pro"]),
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6", "google/gemini-3-pro"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6", "github-copilot/claude-opus-4-6-preview"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -338,14 +338,14 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("skips fallback chain when not provided", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -360,7 +360,7 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -377,7 +377,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -385,7 +385,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
@@ -476,7 +476,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "nonexistent-model" },
|
||||
],
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
|
||||
@@ -494,7 +494,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined, // no system default configured
|
||||
@@ -513,7 +513,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai", "google"])
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic", "openai"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
@@ -523,7 +523,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should use connected provider (openai) from fallback chain
|
||||
expect(result!.model).toBe("openai/claude-opus-4-5")
|
||||
expect(result!.model).toBe("openai/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -556,14 +556,14 @@ describe("resolveModelWithFallback", () => {
|
||||
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "quotio/claude-opus-4-5-20251101",
|
||||
systemDefaultModel: "quotio/claude-opus-4-6-20251101",
|
||||
}
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - no provider in fallback is connected, fall through to system default
|
||||
expect(result!.model).toBe("quotio/claude-opus-4-5-20251101")
|
||||
expect(result!.model).toBe("quotio/claude-opus-4-6-20251101")
|
||||
expect(result!.source).toBe("system-default")
|
||||
cacheSpy.mockRestore()
|
||||
})
|
||||
@@ -573,7 +573,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
@@ -607,20 +607,20 @@ describe("resolveModelWithFallback", () => {
|
||||
describe("Multi-entry fallbackChain", () => {
|
||||
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
|
||||
// given
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-5"])
|
||||
const availableModels = new Set(["anthropic/claude-opus-4-6"])
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
|
||||
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-6", variant: "max" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
})
|
||||
|
||||
// then
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
@@ -647,14 +647,14 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const availableModels = new Set([
|
||||
"openai/gpt-5.2",
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-opus-4-6",
|
||||
])
|
||||
|
||||
// when
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels,
|
||||
systemDefaultModel: "system/default",
|
||||
@@ -673,7 +673,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback({
|
||||
fallbackChain: [
|
||||
{ providers: ["openai"], model: "gpt-5.2" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
],
|
||||
availableModels,
|
||||
@@ -690,7 +690,7 @@ describe("resolveModelWithFallback", () => {
|
||||
test("result has correct ModelResolutionResult shape", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: "google/gemini-3-pro",
|
||||
}
|
||||
@@ -713,7 +713,7 @@ describe("resolveModelWithFallback", () => {
|
||||
fallbackChain: [
|
||||
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "anthropic/claude-sonnet-4-5",
|
||||
}
|
||||
|
||||
@@ -749,9 +749,9 @@ describe("resolveModelWithFallback", () => {
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
@@ -759,19 +759,19 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - should fall through to fallbackChain
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
|
||||
test("userModel takes priority over categoryDefaultModel", () => {
|
||||
// given - both userModel and categoryDefaultModel provided
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
categoryDefaultModel: "google/gemini-3-pro",
|
||||
fallbackChain: [
|
||||
{ providers: ["google"], model: "gemini-3-pro" },
|
||||
],
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["google/gemini-3-pro-preview", "anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: "system/default",
|
||||
}
|
||||
|
||||
@@ -779,7 +779,7 @@ describe("resolveModelWithFallback", () => {
|
||||
const result = resolveModelWithFallback(input)
|
||||
|
||||
// then - userModel wins
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
@@ -837,7 +837,7 @@ describe("resolveModelWithFallback", () => {
|
||||
test("still returns override when userModel provided even if systemDefaultModel undefined", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
userModel: "anthropic/claude-opus-4-5",
|
||||
userModel: "anthropic/claude-opus-4-6",
|
||||
availableModels: new Set(),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
@@ -847,7 +847,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("override")
|
||||
})
|
||||
|
||||
@@ -855,9 +855,9 @@ describe("resolveModelWithFallback", () => {
|
||||
// given
|
||||
const input: ExtendedModelResolutionInput = {
|
||||
fallbackChain: [
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-5" },
|
||||
{ providers: ["anthropic"], model: "claude-opus-4-6" },
|
||||
],
|
||||
availableModels: new Set(["anthropic/claude-opus-4-5"]),
|
||||
availableModels: new Set(["anthropic/claude-opus-4-6"]),
|
||||
systemDefaultModel: undefined,
|
||||
}
|
||||
|
||||
@@ -866,7 +866,7 @@ describe("resolveModelWithFallback", () => {
|
||||
|
||||
// then
|
||||
expect(result).toBeDefined()
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-5")
|
||||
expect(result!.model).toBe("anthropic/claude-opus-4-6")
|
||||
expect(result!.source).toBe("provider-fallback")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Ollama NDJSON Parser
|
||||
*
|
||||
* Parses newline-delimited JSON (NDJSON) responses from Ollama API.
|
||||
*
|
||||
* @module ollama-ndjson-parser
|
||||
* @see https://github.com/code-yeongyu/oh-my-opencode/issues/1124
|
||||
* @see https://github.com/ollama/ollama/blob/main/docs/api.md
|
||||
*/
|
||||
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Ollama message structure
|
||||
*/
|
||||
export interface OllamaMessage {
|
||||
tool_calls?: Array<{
|
||||
function: {
|
||||
name: string
|
||||
arguments: Record<string, unknown>
|
||||
}
|
||||
}>
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama NDJSON line structure
|
||||
*/
|
||||
export interface OllamaNDJSONLine {
|
||||
message?: OllamaMessage
|
||||
done: boolean
|
||||
total_duration?: number
|
||||
load_duration?: number
|
||||
prompt_eval_count?: number
|
||||
prompt_eval_duration?: number
|
||||
eval_count?: number
|
||||
eval_duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Merged Ollama response
|
||||
*/
|
||||
export interface OllamaMergedResponse {
|
||||
message: OllamaMessage
|
||||
done: boolean
|
||||
stats?: {
|
||||
total_duration?: number
|
||||
load_duration?: number
|
||||
prompt_eval_count?: number
|
||||
prompt_eval_duration?: number
|
||||
eval_count?: number
|
||||
eval_duration?: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Ollama streaming NDJSON response into a single merged object.
|
||||
*
|
||||
* Ollama returns streaming responses as newline-delimited JSON (NDJSON):
|
||||
* ```
|
||||
* {"message":{"tool_calls":[...]}, "done":false}
|
||||
* {"message":{"content":""}, "done":true}
|
||||
* ```
|
||||
*
|
||||
* This function:
|
||||
* 1. Splits the response by newlines
|
||||
* 2. Parses each line as JSON
|
||||
* 3. Merges tool_calls and content from all lines
|
||||
* 4. Returns a single merged response
|
||||
*
|
||||
* @param response - Raw NDJSON response string from Ollama API
|
||||
* @returns Merged response with all tool_calls and content combined
|
||||
* @throws {Error} If no valid JSON lines are found
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ndjsonResponse = `
|
||||
* {"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
|
||||
* {"message":{"content":""}, "done":true}
|
||||
* `;
|
||||
*
|
||||
* const merged = parseOllamaStreamResponse(ndjsonResponse);
|
||||
* // Result:
|
||||
* // {
|
||||
* // message: {
|
||||
* // tool_calls: [{ function: { name: "read", arguments: { filePath: "README.md" } } }],
|
||||
* // content: ""
|
||||
* // },
|
||||
* // done: true
|
||||
* // }
|
||||
* ```
|
||||
*/
|
||||
export function parseOllamaStreamResponse(response: string): OllamaMergedResponse {
|
||||
const lines = response.split("\n").filter((line) => line.trim())
|
||||
|
||||
if (lines.length === 0) {
|
||||
throw new Error("No valid NDJSON lines found in response")
|
||||
}
|
||||
|
||||
const mergedMessage: OllamaMessage = {
|
||||
tool_calls: [],
|
||||
content: "",
|
||||
}
|
||||
|
||||
let done = false
|
||||
let stats: OllamaMergedResponse["stats"] = {}
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as OllamaNDJSONLine
|
||||
|
||||
// Merge tool_calls
|
||||
if (json.message?.tool_calls) {
|
||||
mergedMessage.tool_calls = [
|
||||
...(mergedMessage.tool_calls || []),
|
||||
...json.message.tool_calls,
|
||||
]
|
||||
}
|
||||
|
||||
// Merge content (concatenate)
|
||||
if (json.message?.content) {
|
||||
mergedMessage.content = (mergedMessage.content || "") + json.message.content
|
||||
}
|
||||
|
||||
// Update done flag (final line has done: true)
|
||||
if (json.done) {
|
||||
done = true
|
||||
|
||||
// Capture stats from final line
|
||||
stats = {
|
||||
total_duration: json.total_duration,
|
||||
load_duration: json.load_duration,
|
||||
prompt_eval_count: json.prompt_eval_count,
|
||||
prompt_eval_duration: json.prompt_eval_duration,
|
||||
eval_count: json.eval_count,
|
||||
eval_duration: json.eval_duration,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log(`[ollama-ndjson-parser] Skipping malformed NDJSON line: ${line}`, { error })
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
message: mergedMessage,
|
||||
done,
|
||||
...(Object.keys(stats).length > 0 ? { stats } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a response string is NDJSON format.
|
||||
*
|
||||
* NDJSON is identified by:
|
||||
* - Multiple lines
|
||||
* - Each line is valid JSON
|
||||
* - At least one line has "done" field
|
||||
*
|
||||
* @param response - Response string to check
|
||||
* @returns true if response appears to be NDJSON
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const ndjson = '{"done":false}\n{"done":true}';
|
||||
* const singleJson = '{"done":true}';
|
||||
*
|
||||
* isNDJSONResponse(ndjson); // true
|
||||
* isNDJSONResponse(singleJson); // false
|
||||
* ```
|
||||
*/
|
||||
export function isNDJSONResponse(response: string): boolean {
|
||||
const lines = response.split("\n").filter((line) => line.trim())
|
||||
|
||||
// Single line is not NDJSON
|
||||
if (lines.length <= 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
let hasValidJSON = false
|
||||
let hasDoneField = false
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const json = JSON.parse(line) as Record<string, unknown>
|
||||
hasValidJSON = true
|
||||
|
||||
if ("done" in json) {
|
||||
hasDoneField = true
|
||||
}
|
||||
} catch {
|
||||
// If any line fails to parse, it's not NDJSON
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return hasValidJSON && hasDoneField
|
||||
}
|
||||
194
src/shared/pattern-matcher.test.ts
Normal file
194
src/shared/pattern-matcher.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { matchesToolMatcher, findMatchingHooks } from "./pattern-matcher"
|
||||
import type { ClaudeHooksConfig } from "../hooks/claude-code-hooks/types"
|
||||
|
||||
describe("matchesToolMatcher", () => {
|
||||
describe("exact matching", () => {
|
||||
//#given a pattern without wildcards
|
||||
//#when matching against a tool name
|
||||
//#then it should match case-insensitively
|
||||
|
||||
test("matches exact tool name", () => {
|
||||
expect(matchesToolMatcher("bash", "bash")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches case-insensitively", () => {
|
||||
expect(matchesToolMatcher("Bash", "bash")).toBe(true)
|
||||
expect(matchesToolMatcher("bash", "BASH")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match different tool names", () => {
|
||||
expect(matchesToolMatcher("bash", "edit")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("wildcard matching", () => {
|
||||
//#given a pattern with asterisk wildcard
|
||||
//#when matching against tool names
|
||||
//#then it should treat * as glob-style wildcard
|
||||
|
||||
test("matches prefix wildcard", () => {
|
||||
expect(matchesToolMatcher("lsp_goto_definition", "lsp_*")).toBe(true)
|
||||
expect(matchesToolMatcher("lsp_find_references", "lsp_*")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches suffix wildcard", () => {
|
||||
expect(matchesToolMatcher("file_read", "*_read")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches middle wildcard", () => {
|
||||
expect(matchesToolMatcher("get_user_info", "get_*_info")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches multiple wildcards", () => {
|
||||
expect(matchesToolMatcher("get_user_data", "*_user_*")).toBe(true)
|
||||
})
|
||||
|
||||
test("single asterisk matches any tool", () => {
|
||||
expect(matchesToolMatcher("anything", "*")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("pipe-separated patterns", () => {
|
||||
//#given multiple patterns separated by pipes
|
||||
//#when matching against tool names
|
||||
//#then it should match if any pattern matches
|
||||
|
||||
test("matches first pattern", () => {
|
||||
expect(matchesToolMatcher("bash", "bash | edit | write")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches middle pattern", () => {
|
||||
expect(matchesToolMatcher("edit", "bash | edit | write")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches last pattern", () => {
|
||||
expect(matchesToolMatcher("write", "bash | edit | write")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not match if none match", () => {
|
||||
expect(matchesToolMatcher("read", "bash | edit | write")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("regex special character escaping (issue #1521)", () => {
|
||||
//#given a pattern containing regex special characters
|
||||
//#when matching against tool names
|
||||
//#then it should NOT throw SyntaxError and should handle them as literals
|
||||
|
||||
test("handles parentheses in pattern without throwing", () => {
|
||||
expect(() => matchesToolMatcher("bash", "bash(*)")).not.toThrow()
|
||||
expect(matchesToolMatcher("bash(test)", "bash(*)")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles unmatched opening parenthesis", () => {
|
||||
expect(() => matchesToolMatcher("test", "test(*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test(foo", "test(*")).toBe(true)
|
||||
expect(matchesToolMatcher("testfoo", "test(*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles unmatched closing parenthesis", () => {
|
||||
expect(() => matchesToolMatcher("test", "test*)")).not.toThrow()
|
||||
expect(matchesToolMatcher("test)", "test*)")).toBe(true)
|
||||
expect(matchesToolMatcher("testanything)", "test*)")).toBe(true)
|
||||
expect(matchesToolMatcher("foo)", "test*)")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles square brackets", () => {
|
||||
expect(() => matchesToolMatcher("test", "test[*]")).not.toThrow()
|
||||
expect(matchesToolMatcher("test[1]", "test[*]")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles plus sign as literal", () => {
|
||||
expect(() => matchesToolMatcher("test", "test+*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test+value", "test+*")).toBe(true)
|
||||
expect(matchesToolMatcher("testvalue", "test+*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles question mark as literal", () => {
|
||||
expect(() => matchesToolMatcher("test", "test?*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test?foo", "test?*")).toBe(true)
|
||||
expect(matchesToolMatcher("testfoo", "test?*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles caret as literal", () => {
|
||||
expect(() => matchesToolMatcher("test", "^test*")).not.toThrow()
|
||||
expect(matchesToolMatcher("^test_tool", "^test*")).toBe(true)
|
||||
expect(matchesToolMatcher("test_tool", "^test*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles dollar sign as literal", () => {
|
||||
expect(() => matchesToolMatcher("test", "test$*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test$var", "test$*")).toBe(true)
|
||||
expect(matchesToolMatcher("testvar", "test$*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles curly braces as literal", () => {
|
||||
expect(() => matchesToolMatcher("test", "test{*}")).not.toThrow()
|
||||
expect(matchesToolMatcher("test{foo}", "test{*}")).toBe(true)
|
||||
expect(matchesToolMatcher("testfoo", "test{*}")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles pipe as pattern separator", () => {
|
||||
expect(() => matchesToolMatcher("test", "test|value")).not.toThrow()
|
||||
expect(matchesToolMatcher("test", "test|value")).toBe(true)
|
||||
expect(matchesToolMatcher("value", "test|value")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles backslash as literal", () => {
|
||||
expect(() => matchesToolMatcher("test\\path", "test\\*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test\\path", "test\\*")).toBe(true)
|
||||
expect(matchesToolMatcher("testpath", "test\\*")).toBe(false)
|
||||
})
|
||||
|
||||
test("handles dot", () => {
|
||||
expect(() => matchesToolMatcher("test.ts", "test.*")).not.toThrow()
|
||||
expect(matchesToolMatcher("test.ts", "test.*")).toBe(true)
|
||||
})
|
||||
|
||||
test("complex pattern with multiple special chars", () => {
|
||||
expect(() => matchesToolMatcher("func(arg)", "func(*)")).not.toThrow()
|
||||
expect(matchesToolMatcher("func(arg)", "func(*)")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("empty matcher", () => {
|
||||
//#given an empty or undefined matcher
|
||||
//#when matching
|
||||
//#then it should match everything
|
||||
|
||||
test("empty string matches everything", () => {
|
||||
expect(matchesToolMatcher("anything", "")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("findMatchingHooks", () => {
|
||||
const mockHooks: ClaudeHooksConfig = {
|
||||
PreToolUse: [
|
||||
{ matcher: "bash", hooks: [{ type: "command", command: "/test/hook1" }] },
|
||||
{ matcher: "edit*", hooks: [{ type: "command", command: "/test/hook2" }] },
|
||||
{ matcher: "*", hooks: [{ type: "command", command: "/test/hook3" }] },
|
||||
],
|
||||
}
|
||||
|
||||
test("finds hooks matching exact tool name", () => {
|
||||
const result = findMatchingHooks(mockHooks, "PreToolUse", "bash")
|
||||
expect(result.length).toBe(2) // "bash" and "*"
|
||||
})
|
||||
|
||||
test("finds hooks matching wildcard pattern", () => {
|
||||
const result = findMatchingHooks(mockHooks, "PreToolUse", "edit_file")
|
||||
expect(result.length).toBe(2) // "edit*" and "*"
|
||||
})
|
||||
|
||||
test("returns all hooks when no toolName provided", () => {
|
||||
const result = findMatchingHooks(mockHooks, "PreToolUse")
|
||||
expect(result.length).toBe(3)
|
||||
})
|
||||
|
||||
test("returns empty array for non-existent event", () => {
|
||||
const result = findMatchingHooks(mockHooks, "PostToolUse", "bash")
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { ClaudeHooksConfig, HookMatcher } from "../hooks/claude-code-hooks/types"
|
||||
|
||||
/**
|
||||
* Escape all regex special characters EXCEPT asterisk (*).
|
||||
* Asterisk is preserved for glob-to-regex conversion.
|
||||
*/
|
||||
function escapeRegexExceptAsterisk(str: string): string {
|
||||
// Escape all regex special chars except * (which we convert to .* for glob matching)
|
||||
return str.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||
if (!matcher) {
|
||||
return true
|
||||
@@ -7,7 +16,9 @@ export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||
const patterns = matcher.split("|").map((p) => p.trim())
|
||||
return patterns.some((p) => {
|
||||
if (p.includes("*")) {
|
||||
const regex = new RegExp(`^${p.replace(/\*/g, ".*")}$`, "i")
|
||||
// First escape regex special chars (except *), then convert * to .*
|
||||
const escaped = escapeRegexExceptAsterisk(p)
|
||||
const regex = new RegExp(`^${escaped.replace(/\*/g, ".*")}$`, "i")
|
||||
return regex.test(toolName)
|
||||
}
|
||||
return p.toLowerCase() === toolName.toLowerCase()
|
||||
|
||||
105
src/shared/port-utils.test.ts
Normal file
105
src/shared/port-utils.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "bun:test"
|
||||
import {
|
||||
isPortAvailable,
|
||||
findAvailablePort,
|
||||
getAvailableServerPort,
|
||||
DEFAULT_SERVER_PORT,
|
||||
} from "./port-utils"
|
||||
|
||||
describe("port-utils", () => {
|
||||
describe("isPortAvailable", () => {
|
||||
it("#given unused port #when checking availability #then returns true", async () => {
|
||||
const port = 59999
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("#given port in use #when checking availability #then returns false", async () => {
|
||||
const port = 59998
|
||||
const blocker = Bun.serve({
|
||||
port,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await isPortAvailable(port)
|
||||
expect(result).toBe(false)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("findAvailablePort", () => {
|
||||
it("#given start port available #when finding port #then returns start port", async () => {
|
||||
const startPort = 59997
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort)
|
||||
})
|
||||
|
||||
it("#given start port blocked #when finding port #then returns next available", async () => {
|
||||
const startPort = 59996
|
||||
const blocker = Bun.serve({
|
||||
port: startPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 1)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
|
||||
const startPort = 59993
|
||||
const blockers = [
|
||||
Bun.serve({ port: startPort, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 1, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
Bun.serve({ port: startPort + 2, hostname: "127.0.0.1", fetch: () => new Response() }),
|
||||
]
|
||||
|
||||
try {
|
||||
const result = await findAvailablePort(startPort)
|
||||
expect(result).toBe(startPort + 3)
|
||||
} finally {
|
||||
blockers.forEach((b) => b.stop(true))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableServerPort", () => {
|
||||
it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
|
||||
const preferredPort = 59990
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBe(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(false)
|
||||
})
|
||||
|
||||
it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
|
||||
const preferredPort = 59989
|
||||
const blocker = Bun.serve({
|
||||
port: preferredPort,
|
||||
hostname: "127.0.0.1",
|
||||
fetch: () => new Response("blocked"),
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getAvailableServerPort(preferredPort)
|
||||
expect(result.port).toBeGreaterThan(preferredPort)
|
||||
expect(result.wasAutoSelected).toBe(true)
|
||||
} finally {
|
||||
blocker.stop(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("DEFAULT_SERVER_PORT", () => {
|
||||
it("#given constant #when accessed #then returns 4096", () => {
|
||||
expect(DEFAULT_SERVER_PORT).toBe(4096)
|
||||
})
|
||||
})
|
||||
})
|
||||
48
src/shared/port-utils.ts
Normal file
48
src/shared/port-utils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
const DEFAULT_SERVER_PORT = 4096
|
||||
const MAX_PORT_ATTEMPTS = 20
|
||||
|
||||
export async function isPortAvailable(port: number, hostname: string = "127.0.0.1"): Promise<boolean> {
|
||||
try {
|
||||
const server = Bun.serve({
|
||||
port,
|
||||
hostname,
|
||||
fetch: () => new Response(),
|
||||
})
|
||||
server.stop(true)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function findAvailablePort(
|
||||
startPort: number = DEFAULT_SERVER_PORT,
|
||||
hostname: string = "127.0.0.1"
|
||||
): Promise<number> {
|
||||
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
|
||||
const port = startPort + attempt
|
||||
if (await isPortAvailable(port, hostname)) {
|
||||
return port
|
||||
}
|
||||
}
|
||||
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
|
||||
}
|
||||
|
||||
export interface AutoPortResult {
|
||||
port: number
|
||||
wasAutoSelected: boolean
|
||||
}
|
||||
|
||||
export async function getAvailableServerPort(
|
||||
preferredPort: number = DEFAULT_SERVER_PORT,
|
||||
hostname: string = "127.0.0.1"
|
||||
): Promise<AutoPortResult> {
|
||||
if (await isPortAvailable(preferredPort, hostname)) {
|
||||
return { port: preferredPort, wasAutoSelected: false }
|
||||
}
|
||||
|
||||
const port = await findAvailablePort(preferredPort + 1, hostname)
|
||||
return { port, wasAutoSelected: true }
|
||||
}
|
||||
|
||||
export { DEFAULT_SERVER_PORT }
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
20+ tools across 7 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
|
||||
25+ tools across 8 categories. Two patterns: Direct ToolDefinition (static) and Factory Function (context-dependent).
|
||||
|
||||
**Categories**: LSP (6), AST-Grep (2), Search (2), Session (4), Agent delegation (2), Background (2), Skill (3)
|
||||
**Categories**: LSP (6), AST-Grep (2), Search (2), Session (4), Task (4), Agent delegation (2), Background (2), Skill (3), System (2)
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
@@ -15,20 +15,20 @@ tools/
|
||||
│ ├── tools.ts # ToolDefinition or factory
|
||||
│ ├── types.ts # Zod schemas
|
||||
│ └── constants.ts # Fixed values
|
||||
├── lsp/ # 6 tools: definition, references, symbols, diagnostics, rename (client.ts 540 lines)
|
||||
├── lsp/ # 6 tools: definition, references, symbols, diagnostics, rename (client.ts 803 lines)
|
||||
├── ast-grep/ # 2 tools: search, replace (25 languages)
|
||||
├── delegate-task/ # Category-based routing (1135 lines)
|
||||
├── delegate-task/ # Category-based routing (executor.ts 983 lines, constants.ts 552 lines)
|
||||
├── task/ # 4 tools: create, get, list, update (Claude Code compatible)
|
||||
├── session-manager/ # 4 tools: list, read, search, info
|
||||
├── grep/ # Custom grep with timeout (60s, 10MB)
|
||||
├── glob/ # 60s timeout, 100 file limit
|
||||
├── interactive-bash/ # Tmux session management
|
||||
├── look-at/ # Multimodal PDF/image
|
||||
├── look-at/ # Multimodal PDF/image (307 lines)
|
||||
├── skill/ # Skill execution
|
||||
├── skill-mcp/ # Skill MCP operations
|
||||
├── slashcommand/ # Slash command dispatch
|
||||
├── call-omo-agent/ # Direct agent invocation
|
||||
└── background-task/ # background_output, background_cancel
|
||||
├── call-omo-agent/ # Direct agent invocation (358 lines)
|
||||
└── background-task/ # background_output, background_cancel (734 lines)
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
@@ -49,13 +49,9 @@ tools/
|
||||
Claude Code compatible task management.
|
||||
|
||||
- **task_create**: Creates a new task. Auto-generates ID and syncs to Todo.
|
||||
- Args: `subject`, `description`, `activeForm`, `blocks`, `blockedBy`, `owner`, `metadata`
|
||||
- **task_get**: Retrieves a task by ID.
|
||||
- Args: `id`
|
||||
- **task_list**: Lists active tasks. Filters out completed/deleted by default.
|
||||
- Args: `status`, `parentID`
|
||||
- **task_update**: Updates task fields. Supports additive `addBlocks`/`addBlockedBy`.
|
||||
- Args: `id`, `subject`, `description`, `status`, `activeForm`, `addBlocks`, `addBlockedBy`, `owner`, `metadata`
|
||||
|
||||
## HOW TO ADD
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user