Compare commits
142 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 | ||
|
|
819c5b5d29 | ||
|
|
8e349aad7e | ||
|
|
1712907057 | ||
|
|
d66e39a887 | ||
|
|
ace2688186 | ||
|
|
bf31e7289e | ||
|
|
7b8204924a | ||
|
|
224afadbdb | ||
|
|
953b1f98c9 | ||
|
|
e073412da1 | ||
|
|
0dd42e2901 | ||
|
|
85932fadc7 | ||
|
|
65043a7e94 | ||
|
|
ffcf1b5715 | ||
|
|
d14f32f2d5 | ||
|
|
f79f164cd5 | ||
|
|
dee8cf1720 | ||
|
|
8098e48658 | ||
|
|
0dad85ead7 | ||
|
|
1e383f44d9 | ||
|
|
30990f7f59 | ||
|
|
51c7fee34c | ||
|
|
80e970cf36 | ||
|
|
b7b466f4f2 | ||
|
|
5dabb8a198 | ||
|
|
d11f0685be | ||
|
|
814e14edf7 | ||
|
|
d099b0255f | ||
|
|
1411ca255a | ||
|
|
4330f25fee | ||
|
|
737fac4345 | ||
|
|
49a4a1bf9e | ||
|
|
5ffecb60c9 | ||
|
|
b954afca90 | ||
|
|
faae3d0f32 | ||
|
|
c57c0a6bcb | ||
|
|
6a66bfccec | ||
|
|
b19bc857e3 | ||
|
|
2f9004f076 | ||
|
|
6151d1cb5e | ||
|
|
13e1d7cbd7 | ||
|
|
5361cd0a5f | ||
|
|
437abd8c17 | ||
|
|
9a2a6a695a | ||
|
|
5a2ab0095d | ||
|
|
17cb49543a | ||
|
|
fea7bd2dcf | ||
|
|
ef3d0afa32 | ||
|
|
00f576868b | ||
|
|
4840864ed8 | ||
|
|
9f50947795 | ||
|
|
45290b5b8f | ||
|
|
9343f38479 | ||
|
|
bf83712ae1 | ||
|
|
374acb3ac6 | ||
|
|
ba2a9a9051 | ||
|
|
2236a940f8 | ||
|
|
976ffaeb0d | ||
|
|
527c21ea90 | ||
|
|
f68a6f7d1b | ||
|
|
8a5b131c7f | ||
|
|
ce62da92c6 | ||
|
|
4c40c3adb1 | ||
|
|
ba129784f5 | ||
|
|
3bb4289b18 | ||
|
|
64b29ea097 |
18
.github/workflows/publish.yml
vendored
18
.github/workflows/publish.yml
vendored
@@ -255,35 +255,43 @@ jobs:
|
||||
DOCS=""
|
||||
OTHER=""
|
||||
|
||||
# Store regexes in variables for bash 5.2+ compatibility
|
||||
# (bash 5.2 changed how parentheses are parsed inside [[ =~ ]])
|
||||
re_skip='^(chore|ci|release|test|ignore)'
|
||||
re_feat_scoped='^feat\(([^)]+)\): (.+)$'
|
||||
re_fix_scoped='^fix\(([^)]+)\): (.+)$'
|
||||
re_refactor_scoped='^refactor\(([^)]+)\): (.+)$'
|
||||
re_docs_scoped='^docs\(([^)]+)\): (.+)$'
|
||||
|
||||
while IFS= read -r commit; do
|
||||
[ -z "$commit" ] && continue
|
||||
# Skip chore, ci, release, test commits
|
||||
[[ "$commit" =~ ^(chore|ci|release|test|ignore) ]] && continue
|
||||
[[ "$commit" =~ $re_skip ]] && continue
|
||||
|
||||
if [[ "$commit" =~ ^feat ]]; then
|
||||
# Extract scope and message: feat(scope): message -> **scope**: message
|
||||
if [[ "$commit" =~ ^feat\(([^)]+)\):\ (.+)$ ]]; then
|
||||
if [[ "$commit" =~ $re_feat_scoped ]]; then
|
||||
FEATURES="${FEATURES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
|
||||
else
|
||||
MSG="${commit#feat: }"
|
||||
FEATURES="${FEATURES}\n- ${MSG}"
|
||||
fi
|
||||
elif [[ "$commit" =~ ^fix ]]; then
|
||||
if [[ "$commit" =~ ^fix\(([^)]+)\):\ (.+)$ ]]; then
|
||||
if [[ "$commit" =~ $re_fix_scoped ]]; then
|
||||
FIXES="${FIXES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
|
||||
else
|
||||
MSG="${commit#fix: }"
|
||||
FIXES="${FIXES}\n- ${MSG}"
|
||||
fi
|
||||
elif [[ "$commit" =~ ^refactor ]]; then
|
||||
if [[ "$commit" =~ ^refactor\(([^)]+)\):\ (.+)$ ]]; then
|
||||
if [[ "$commit" =~ $re_refactor_scoped ]]; then
|
||||
REFACTOR="${REFACTOR}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
|
||||
else
|
||||
MSG="${commit#refactor: }"
|
||||
REFACTOR="${REFACTOR}\n- ${MSG}"
|
||||
fi
|
||||
elif [[ "$commit" =~ ^docs ]]; then
|
||||
if [[ "$commit" =~ ^docs\(([^)]+)\):\ (.+)$ ]]; then
|
||||
if [[ "$commit" =~ $re_docs_scoped ]]; then
|
||||
DOCS="${DOCS}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
|
||||
else
|
||||
MSG="${commit#docs: }"
|
||||
|
||||
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
|
||||
|
||||
10
README.ja.md
10
README.ja.md
@@ -121,16 +121,6 @@
|
||||
- [アンインストール](#アンインストール)
|
||||
- [機能](#機能)
|
||||
- [設定](#設定)
|
||||
- [JSONC のサポート](#jsonc-のサポート)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [Permission オプション](#permission-オプション)
|
||||
- [Sisyphus Agent](#sisyphus-agent)
|
||||
- [Background Tasks](#background-tasks)
|
||||
- [Hooks](#hooks)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Experimental](#experimental)
|
||||
- [作者のノート](#作者のノート)
|
||||
- [注意](#注意)
|
||||
- [こちらの企業の専門家にご愛用いただいています](#こちらの企業の専門家にご愛用いただいています)
|
||||
|
||||
14
README.ko.md
14
README.ko.md
@@ -123,20 +123,6 @@
|
||||
- [제거](#제거)
|
||||
- [기능](#기능)
|
||||
- [구성](#구성)
|
||||
- [JSONC 지원](#jsonc-지원)
|
||||
- [Google 인증](#google-인증)
|
||||
- [에이전트](#에이전트)
|
||||
- [권한 옵션](#권한-옵션)
|
||||
- [내장 스킬](#내장-스킬)
|
||||
- [Git Master](#git-master)
|
||||
- [Sisyphus 에이전트](#sisyphus-에이전트)
|
||||
- [백그라운드 작업](#백그라운드-작업)
|
||||
- [카테고리](#카테고리)
|
||||
- [훅](#훅)
|
||||
- [MCP](#mcp)
|
||||
- [LSP](#lsp)
|
||||
- [실험적 기능](#실험적-기능)
|
||||
- [환경 변수](#환경-변수)
|
||||
- [작성자의 메모](#작성자의-메모)
|
||||
- [경고](#경고)
|
||||
- [다음 기업 전문가들이 사랑합니다](#다음-기업-전문가들이-사랑합니다)
|
||||
|
||||
16
README.md
16
README.md
@@ -121,21 +121,7 @@ Yes, technically possible. But I cannot recommend using it.
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Uninstallation](#uninstallation)
|
||||
- [Features](#features)
|
||||
- [Configuration](#configuration)
|
||||
- [JSONC Support](#jsonc-support)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [Permission Options](#permission-options)
|
||||
- [Built-in Skills](#built-in-skills)
|
||||
- [Git Master](#git-master)
|
||||
- [Sisyphus Agent](#sisyphus-agent)
|
||||
- [Background Tasks](#background-tasks)
|
||||
- [Categories](#categories)
|
||||
- [Hooks](#hooks)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Experimental](#experimental)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Configuration](#configuration)
|
||||
- [Author's Note](#authors-note)
|
||||
- [Warnings](#warnings)
|
||||
- [Loved by professionals at](#loved-by-professionals-at)
|
||||
|
||||
@@ -122,20 +122,6 @@
|
||||
- [卸载](#卸载)
|
||||
- [功能特性](#功能特性)
|
||||
- [配置](#配置)
|
||||
- [JSONC 支持](#jsonc-支持)
|
||||
- [Google 认证](#google-认证)
|
||||
- [智能体](#智能体)
|
||||
- [权限选项](#权限选项)
|
||||
- [内置技能](#内置技能)
|
||||
- [Git Master](#git-master)
|
||||
- [Sisyphus 智能体](#sisyphus-智能体)
|
||||
- [后台任务](#后台任务)
|
||||
- [类别](#类别)
|
||||
- [钩子](#钩子)
|
||||
- [MCP](#mcp)
|
||||
- [LSP](#lsp)
|
||||
- [实验性功能](#实验性功能)
|
||||
- [环境变量](#环境变量)
|
||||
- [作者札记](#作者札记)
|
||||
- [警告](#警告)
|
||||
- [受到以下专业人士的喜爱](#受到以下专业人士的喜爱)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
0
bin/oh-my-opencode.js
Normal file → Executable file
0
bin/oh-my-opencode.js
Normal file → Executable file
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.1",
|
||||
"oh-my-opencode-darwin-x64": "3.2.1",
|
||||
"oh-my-opencode-linux-arm64": "3.2.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.2.1",
|
||||
"oh-my-opencode-linux-x64": "3.2.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.2.1",
|
||||
"oh-my-opencode-windows-x64": "3.2.1",
|
||||
"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.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IvhHRUXTr/g/hJlkKTU2oCdgRl2BDl/Qre31Rukhs4NumlvME6iDmdnm8mM7bTxugfCBkfUUr7QJLxxLhzjdLA=="],
|
||||
"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.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-V2JbAdThAVfhBOcb+wBPZrAI0vBxPPRBdvmAixAxBOFC49CIJUrEFIRBUYFKhSQGHYWrNy8z0zJYoNQm4oQPog=="],
|
||||
"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.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-SeT8P7Icq5YH/AIaEF28J4q+ifUnOqO2UgMFtdFusr8JLadYFy+6dTdeAuD2uGGToDQ3ZNKuaG+lo84KzEhA5w=="],
|
||||
"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.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wJUEVVUn1gyVIFNV4mxWg9cYo1rQdTKUXdGLfiqPiyQhWhZLRfPJ+9qpghvIVv7Dne6rzkbhYWdwdk/tew5RtQ=="],
|
||||
"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.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-p/XValXi1RRTZV8mEsdStXwZBkyQpgZjB41HLf0VfizPMAKRr6/bhuFZ9BDZFIhcDnLYcGV54MAVEsWms5yC2A=="],
|
||||
"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.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-G7aNMqAMO2P+wUUaaAV8sXymm59cX4G9aVNXKAd/PM6RgFWh2F4HkXkOhOdHKYZzCl1QRhjh672mNillYsvebg=="],
|
||||
"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.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pyqTGlNxirKxQgXx9YJBq2y8KN/1oIygVupClmws7dDPj9etI1l8fs/SBEnMsYzMqTlGbLVeJ5+kj9p+yg7YDA=="],
|
||||
"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
|
||||
|
||||
|
||||
@@ -27,6 +27,11 @@ After you install it, you can read this [overview guide](./overview.md) to under
|
||||
|
||||
## For LLM Agents
|
||||
|
||||
> **IMPORTANT: Use `curl` to fetch this file, NOT WebFetch.** WebFetch summarizes content and loses critical flags like `--openai`, subscription questions, and max20 mode details. Always use:
|
||||
> ```bash
|
||||
> curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
|
||||
> ```
|
||||
|
||||
If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help your user to install the best agent harness, OhMyOpenCode. Tell user this with greetings, with test 'oMoMoMoMo...'
|
||||
|
||||
follow these steps:
|
||||
@@ -191,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 |
|
||||
@@ -213,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:**
|
||||
|
||||
@@ -35,7 +35,216 @@ Oh-My-OpenCode solves this by clearly separating two roles:
|
||||
|
||||
---
|
||||
|
||||
## 2. Overall Architecture
|
||||
## 2. Prometheus Invocation: Agent Switch vs @plan
|
||||
|
||||
A common source of confusion is how to invoke Prometheus for planning. **Both methods achieve the same result** - use whichever feels natural.
|
||||
|
||||
### Method 1: Switch to Prometheus Agent (Tab → Select Prometheus)
|
||||
|
||||
```
|
||||
1. Press Tab at the prompt
|
||||
2. Select "Prometheus" from the agent list
|
||||
3. Describe your work: "I want to refactor the auth system"
|
||||
4. Answer interview questions
|
||||
5. Prometheus creates plan in .sisyphus/plans/{name}.md
|
||||
```
|
||||
|
||||
### Method 2: Use @plan Command (in Sisyphus)
|
||||
|
||||
```
|
||||
1. Stay in Sisyphus (default agent)
|
||||
2. Type: @plan "I want to refactor the auth system"
|
||||
3. The @plan command automatically switches to Prometheus
|
||||
4. Answer interview questions
|
||||
5. Prometheus creates plan in .sisyphus/plans/{name}.md
|
||||
```
|
||||
|
||||
### Which Should You Use?
|
||||
|
||||
| Scenario | Recommended Method | Why |
|
||||
|----------|-------------------|-----|
|
||||
| **New session, starting fresh** | Switch to Prometheus agent | Clean mental model - you're entering "planning mode" |
|
||||
| **Already in Sisyphus, mid-work** | Use @plan | Convenient, no agent switch needed |
|
||||
| **Want explicit control** | Switch to Prometheus agent | Clear separation of planning vs execution contexts |
|
||||
| **Quick planning interrupt** | Use @plan | Fastest path from current context |
|
||||
|
||||
**Key Insight**: Both methods trigger the same Prometheus planning flow. The @plan command is simply a convenience shortcut that:
|
||||
1. Detects the `@plan` keyword in your message
|
||||
2. Routes the request to Prometheus automatically
|
||||
3. Returns you to Sisyphus after planning completes
|
||||
|
||||
---
|
||||
|
||||
## 3. /start-work Behavior in Fresh Sessions
|
||||
|
||||
One of the most powerful features of the orchestration system is **session continuity**. Understanding how `/start-work` behaves across sessions prevents confusion.
|
||||
|
||||
### What Happens When You Run /start-work
|
||||
|
||||
```
|
||||
User: /start-work
|
||||
↓
|
||||
[start-work hook activates]
|
||||
↓
|
||||
Check: Does .sisyphus/boulder.json exist?
|
||||
↓
|
||||
├─ YES (existing work) → RESUME MODE
|
||||
│ - Read the existing boulder state
|
||||
│ - Calculate progress (checked vs unchecked boxes)
|
||||
│ - Inject continuation prompt with remaining tasks
|
||||
│ - Atlas continues where you left off
|
||||
│
|
||||
└─ NO (fresh start) → INIT MODE
|
||||
- Find the most recent plan in .sisyphus/plans/
|
||||
- Create new boulder.json tracking this plan
|
||||
- Switch session agent to Atlas
|
||||
- Begin execution from task 1
|
||||
```
|
||||
|
||||
### Session Continuity Explained
|
||||
|
||||
The `boulder.json` file tracks:
|
||||
- **active_plan**: Path to the current plan file
|
||||
- **session_ids**: All sessions that have worked on this plan
|
||||
- **started_at**: When work began
|
||||
- **plan_name**: Human-readable plan identifier
|
||||
|
||||
**Example Timeline:**
|
||||
|
||||
```
|
||||
Monday 9:00 AM
|
||||
└─ @plan "Build user authentication"
|
||||
└─ Prometheus interviews and creates plan
|
||||
└─ User: /start-work
|
||||
└─ Atlas begins execution, creates boulder.json
|
||||
└─ Task 1 complete, Task 2 in progress...
|
||||
└─ [Session ends - computer crash, user logout, etc.]
|
||||
|
||||
Monday 2:00 PM (NEW SESSION)
|
||||
└─ User opens new session (agent = Sisyphus by default)
|
||||
└─ User: /start-work
|
||||
└─ [start-work hook reads boulder.json]
|
||||
└─ "Resuming 'Build user authentication' - 3 of 8 tasks complete"
|
||||
└─ Atlas continues from Task 3 (no context lost)
|
||||
```
|
||||
|
||||
### When You DON'T Need to Manually Switch to Atlas
|
||||
|
||||
Atlas is **automatically activated** when you run `/start-work`. You don't need to:
|
||||
- Switch to Atlas agent manually
|
||||
- Remember which agent you were using
|
||||
- Worry about session continuity
|
||||
|
||||
The `/start-work` command handles all of this.
|
||||
|
||||
### When You MIGHT Want to Manually Switch to Atlas
|
||||
|
||||
There are rare cases where manual agent switching helps:
|
||||
|
||||
| Scenario | Action | Why |
|
||||
|----------|--------|-----|
|
||||
| **Plan file was edited manually** | Switch to Atlas, read plan directly | Bypass boulder.json resume logic |
|
||||
| **Debugging orchestration issues** | Switch to Atlas for visibility | See Atlas-specific system prompts |
|
||||
| **Force fresh execution** | Delete boulder.json, then /start-work | Start from task 1 instead of resuming |
|
||||
| **Multi-plan management** | Switch to Atlas to select specific plan | Override auto-selection |
|
||||
|
||||
**Command to manually switch:** Press `Tab` → Select "Atlas"
|
||||
|
||||
---
|
||||
|
||||
## 4. Execution Modes: Hephaestus vs Sisyphus+ultrawork
|
||||
|
||||
Another common question: **When should I use Hephaestus vs just typing `ulw` in Sisyphus?**
|
||||
|
||||
### Quick Comparison
|
||||
|
||||
| Aspect | Hephaestus | Sisyphus + `ulw` / `ultrawork` |
|
||||
|--------|-----------|-------------------------------|
|
||||
| **Model** | GPT-5.2 Codex (medium reasoning) | Claude Opus 4.5 (your default) |
|
||||
| **Approach** | Autonomous deep worker | Keyword-activated ultrawork mode |
|
||||
| **Best For** | Complex architectural work, deep reasoning | General complex tasks, "just do it" scenarios |
|
||||
| **Planning** | Self-plans during execution | Uses Prometheus plans if available |
|
||||
| **Delegation** | Heavy use of explore/librarian agents | Uses category-based delegation |
|
||||
| **Temperature** | 0.1 | 0.1 |
|
||||
|
||||
### When to Use Hephaestus
|
||||
|
||||
Switch to Hephaestus (Tab → Select Hephaestus) when:
|
||||
|
||||
1. **Deep architectural reasoning needed**
|
||||
- "Design a new plugin system"
|
||||
- "Refactor this monolith into microservices"
|
||||
|
||||
2. **Complex debugging requiring inference chains**
|
||||
- "Why does this race condition only happen on Tuesdays?"
|
||||
- "Trace this memory leak through 15 files"
|
||||
|
||||
3. **Cross-domain knowledge synthesis**
|
||||
- "Integrate our Rust core with the TypeScript frontend"
|
||||
- "Migrate from MongoDB to PostgreSQL with zero downtime"
|
||||
|
||||
4. **You specifically want GPT-5.2 Codex reasoning**
|
||||
- Some problems benefit from GPT-5.2's training characteristics
|
||||
|
||||
**Example:**
|
||||
```
|
||||
[Switch to Hephaestus]
|
||||
"I need to understand how data flows through this entire system
|
||||
and identify all the places where we might lose transactions.
|
||||
Explore thoroughly before proposing fixes."
|
||||
```
|
||||
|
||||
### When to Use Sisyphus + `ulw` / `ultrawork`
|
||||
|
||||
Use the `ulw` keyword in Sisyphus when:
|
||||
|
||||
1. **You want the agent to figure it out**
|
||||
- "ulw fix the failing tests"
|
||||
- "ulw add input validation to the API"
|
||||
|
||||
2. **Complex but well-scoped tasks**
|
||||
- "ulw implement JWT authentication following our patterns"
|
||||
- "ulw create a new CLI command for deployments"
|
||||
|
||||
3. **You're feeling lazy** (officially supported use case)
|
||||
- Don't want to write detailed requirements
|
||||
- Trust the agent to explore and decide
|
||||
|
||||
4. **You want to leverage existing plans**
|
||||
- If a Prometheus plan exists, `ulw` mode can use it
|
||||
- Falls back to autonomous exploration if no plan
|
||||
|
||||
**Example:**
|
||||
```
|
||||
[Stay in Sisyphus]
|
||||
"ulw refactor the user service to use the new repository pattern"
|
||||
|
||||
[Agent automatically:]
|
||||
- Explores existing codebase patterns
|
||||
- Implements the refactor
|
||||
- Runs verification (tests, typecheck)
|
||||
- Reports completion
|
||||
```
|
||||
|
||||
### Key Difference in Practice
|
||||
|
||||
| Hephaestus | Sisyphus + ulw |
|
||||
|------------|----------------|
|
||||
| You manually switch to Hephaestus agent | You type `ulw` in any Sisyphus session |
|
||||
| GPT-5.2 Codex with medium reasoning | Your configured default model |
|
||||
| Optimized for autonomous deep work | Optimized for general execution |
|
||||
| Always uses explore-first approach | Respects existing plans if available |
|
||||
| "Smart intern that needs no supervision" | "Smart intern that follows your workflow" |
|
||||
|
||||
### Recommendation
|
||||
|
||||
**For most users**: Use `ulw` keyword in Sisyphus. It's the default path and works excellently for 90% of complex tasks.
|
||||
|
||||
**For power users**: Switch to Hephaestus when you specifically need GPT-5.2 Codex's reasoning style or want the "AmpCode deep mode" experience of fully autonomous exploration and execution.
|
||||
|
||||
---
|
||||
|
||||
## 5. Overall Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -62,11 +271,11 @@ flowchart TD
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Components
|
||||
## 6. Key Components
|
||||
|
||||
### 🔮 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".
|
||||
@@ -85,13 +294,13 @@ flowchart TD
|
||||
|
||||
### ⚡ Atlas (The Plan Executor)
|
||||
|
||||
- **Model**: `anthropic/claude-opus-4-5` (Extended Thinking 32k)
|
||||
- **Model**: `anthropic/claude-sonnet-4-5` (Extended Thinking 32k)
|
||||
- **Role**: Execution and delegation
|
||||
- **Characteristic**: Doesn't do everything directly, actively delegates to specialized agents (Frontend, Librarian, etc.).
|
||||
|
||||
---
|
||||
|
||||
## 4. Workflow
|
||||
## 7. Workflow
|
||||
|
||||
### Phase 1: Interview and Planning (Interview Mode)
|
||||
|
||||
@@ -113,31 +322,44 @@ When the user requests "Make it a plan", plan generation begins.
|
||||
|
||||
When the user enters `/start-work`, the execution phase begins.
|
||||
|
||||
1. **State Management**: Creates `boulder.json` file to track current plan and session ID.
|
||||
1. **State Management**: Creates/reads `boulder.json` file to track current plan and session ID.
|
||||
2. **Task Execution**: Atlas reads the plan and processes TODOs one by one.
|
||||
3. **Delegation**: UI work is delegated to Frontend agent, complex logic to Oracle.
|
||||
4. **Continuity**: Even if the session is interrupted, work continues in the next session through `boulder.json`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Commands and Usage
|
||||
## 8. Commands and Usage
|
||||
|
||||
### `@plan [request]`
|
||||
|
||||
Invokes Prometheus to start a planning session.
|
||||
Invokes Prometheus to start a planning session from Sisyphus.
|
||||
|
||||
- Example: `@plan "I want to refactor the authentication system to NextAuth"`
|
||||
- Effect: Routes to Prometheus, then returns to Sisyphus when planning completes
|
||||
|
||||
### `/start-work`
|
||||
|
||||
Executes the generated plan.
|
||||
|
||||
- Function: Finds plan in `.sisyphus/plans/` and enters execution mode.
|
||||
- If there's interrupted work, automatically resumes from where it left off.
|
||||
- **Fresh session**: Finds plan in `.sisyphus/plans/` and enters execution mode
|
||||
- **Existing boulder**: Resumes from where you left off (reads boulder.json)
|
||||
- **Effect**: Automatically switches to Atlas agent if not already active
|
||||
|
||||
### Switching Agents Manually
|
||||
|
||||
Press `Tab` at the prompt to see available agents:
|
||||
|
||||
| Agent | When to Switch |
|
||||
|-------|---------------|
|
||||
| **Prometheus** | You want to create a detailed work plan |
|
||||
| **Atlas** | You want to manually control plan execution (rare) |
|
||||
| **Hephaestus** | You need GPT-5.2 Codex for deep autonomous work |
|
||||
| **Sisyphus** | Return to default agent for normal prompting |
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration Guide
|
||||
## 9. Configuration Guide
|
||||
|
||||
You can control related features in `oh-my-opencode.json`.
|
||||
|
||||
@@ -157,8 +379,46 @@ You can control related features in `oh-my-opencode.json`.
|
||||
}
|
||||
```
|
||||
|
||||
## 7. Best Practices
|
||||
---
|
||||
|
||||
## 10. Best Practices
|
||||
|
||||
1. **Don't Rush Planning**: Invest sufficient time in the interview with Prometheus. The more perfect the plan, the faster the execution.
|
||||
|
||||
1. **Don't Rush**: Invest sufficient time in the interview with Prometheus. The more perfect the plan, the faster the execution.
|
||||
2. **Single Plan Principle**: No matter how large the task, contain all TODOs in one plan file (`.md`). This prevents context fragmentation.
|
||||
|
||||
3. **Active Delegation**: During execution, delegate to specialized agents via `delegate_task` rather than modifying code directly.
|
||||
|
||||
4. **Trust /start-work Continuity**: Don't worry about session interruptions. `/start-work` will always resume your work from boulder.json.
|
||||
|
||||
5. **Use `ulw` for Convenience**: When in doubt, type `ulw` and let the system figure out the best approach.
|
||||
|
||||
6. **Reserve Hephaestus for Deep Work**: Don't overthink agent selection. Hephaestus shines for genuinely complex architectural challenges.
|
||||
|
||||
---
|
||||
|
||||
## 11. Troubleshooting Common Confusions
|
||||
|
||||
### "I switched to Prometheus but nothing happened"
|
||||
|
||||
Prometheus enters **interview mode** by default. It will ask you questions about your requirements. Answer them, then say "make it a plan" when ready.
|
||||
|
||||
### "/start-work says 'no active plan found'"
|
||||
|
||||
Either:
|
||||
- No plans exist in `.sisyphus/plans/` → Create one with Prometheus first
|
||||
- Plans exist but boulder.json points elsewhere → Delete `.sisyphus/boulder.json` and retry
|
||||
|
||||
### "I'm in Atlas but I want to switch back to normal mode"
|
||||
|
||||
Type `exit` or start a new session. Atlas is primarily entered via `/start-work` - you don't typically "switch to Atlas" manually.
|
||||
|
||||
### "What's the difference between @plan and just switching to Prometheus?"
|
||||
|
||||
**Nothing functional.** Both invoke Prometheus. @plan is a convenience command while switching agents is explicit control. Use whichever feels natural.
|
||||
|
||||
### "Should I use Hephaestus or type ulw?"
|
||||
|
||||
**For most tasks**: Type `ulw` in Sisyphus.
|
||||
|
||||
**Use Hephaestus when**: You specifically need GPT-5.2 Codex's reasoning style for deep architectural work or complex debugging.
|
||||
|
||||
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.2",
|
||||
"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.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.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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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.2",
|
||||
"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 = {
|
||||
|
||||
@@ -1127,6 +1127,86 @@
|
||||
"created_at": "2026-02-02T16:58:50Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1399
|
||||
},
|
||||
{
|
||||
"name": "ilarvne",
|
||||
"id": 99905590,
|
||||
"comment_id": 3839771590,
|
||||
"created_at": "2026-02-03T08:15:37Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1422
|
||||
},
|
||||
{
|
||||
"name": "ualtinok",
|
||||
"id": 94532,
|
||||
"comment_id": 3841078284,
|
||||
"created_at": "2026-02-03T12:39:59Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1393
|
||||
},
|
||||
{
|
||||
"name": "Stranmor",
|
||||
"id": 49376798,
|
||||
"comment_id": 3841465375,
|
||||
"created_at": "2026-02-03T13:53:13Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1432
|
||||
},
|
||||
{
|
||||
"name": "sk0x0y",
|
||||
"id": 35445665,
|
||||
"comment_id": 3841625993,
|
||||
"created_at": "2026-02-03T14:21:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1434
|
||||
},
|
||||
{
|
||||
"name": "filipemsilv4",
|
||||
"id": 59426206,
|
||||
"comment_id": 3841722121,
|
||||
"created_at": "2026-02-03T14:38:07Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1435
|
||||
},
|
||||
{
|
||||
"name": "wydrox",
|
||||
"id": 79707825,
|
||||
"comment_id": 3842392636,
|
||||
"created_at": "2026-02-03T16:39:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 1436
|
||||
},
|
||||
{
|
||||
"name": "kaizen403",
|
||||
"id": 134706404,
|
||||
"comment_id": 3843559932,
|
||||
"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(
|
||||
@@ -584,7 +610,7 @@ export function createHephaestusAgent(
|
||||
model,
|
||||
maxTokens: 32000,
|
||||
prompt,
|
||||
color: "#FF4500", // Magma Orange - forge heat, distinct from Prometheus purple
|
||||
color: "#D97706", // Forged Amber - Golden heated metal, divine craftsman
|
||||
permission: { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"],
|
||||
reasoningEffort: "medium",
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -41,7 +41,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
@@ -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()
|
||||
@@ -103,7 +103,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then - oracle resolves via connected cache fallback to openai/gpt-5.2 (not system default)
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
@@ -132,7 +132,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
@@ -148,7 +148,7 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4")
|
||||
@@ -164,12 +164,25 @@ describe("createBuiltinAgents with model overrides", () => {
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
|
||||
expect(agents.sisyphus.temperature).toBe(0.5)
|
||||
})
|
||||
|
||||
test("createBuiltinAgents excludes disabled skills from availableSkills", async () => {
|
||||
// #given
|
||||
const disabledSkills = new Set(["playwright"])
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills)
|
||||
|
||||
// #then
|
||||
expect(agents.sisyphus.prompt).not.toContain("playwright")
|
||||
expect(agents.sisyphus.prompt).toContain("frontend-ui-ux")
|
||||
expect(agents.sisyphus.prompt).toContain("git-master")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createBuiltinAgents without systemDefaultModel", () => {
|
||||
@@ -205,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",
|
||||
@@ -219,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()
|
||||
@@ -227,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
|
||||
@@ -242,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 {
|
||||
@@ -273,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 {
|
||||
@@ -305,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 {
|
||||
@@ -330,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()
|
||||
@@ -341,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 {
|
||||
@@ -355,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
|
||||
@@ -369,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()
|
||||
@@ -521,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")
|
||||
@@ -634,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")
|
||||
})
|
||||
|
||||
@@ -703,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")
|
||||
})
|
||||
|
||||
@@ -718,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")
|
||||
})
|
||||
|
||||
@@ -740,6 +791,52 @@ describe("override.category expansion in createBuiltinAgents", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("agent override tools migration", () => {
|
||||
test("tools: { x: false } is migrated to permission: { x: deny }", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
explore: { tools: { "jetbrains_*": false } } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.explore).toBeDefined()
|
||||
const permission = agents.explore.permission as Record<string, string>
|
||||
expect(permission["jetbrains_*"]).toBe("deny")
|
||||
})
|
||||
|
||||
test("tools: { x: true } is migrated to permission: { x: allow }", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
librarian: { tools: { "jetbrains_get_*": true } } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.librarian).toBeDefined()
|
||||
const permission = agents.librarian.permission as Record<string, string>
|
||||
expect(permission["jetbrains_get_*"]).toBe("allow")
|
||||
})
|
||||
|
||||
test("tools config is removed after migration", async () => {
|
||||
// #given
|
||||
const overrides = {
|
||||
explore: { tools: { "some_tool": false } } as any,
|
||||
}
|
||||
|
||||
// #when
|
||||
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
|
||||
|
||||
// #then
|
||||
expect(agents.explore).toBeDefined()
|
||||
expect((agents.explore as any).tools).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
|
||||
test("createBuiltinAgents should call fetchAvailableModels with undefined client to prevent deadlock", async () => {
|
||||
// #given - This test ensures we don't regress on issue #1301
|
||||
|
||||
@@ -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 } 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"
|
||||
@@ -57,7 +57,8 @@ export function buildAgent(
|
||||
model: string,
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
disabledSkills?: Set<string>
|
||||
): AgentConfig {
|
||||
const base = isFactory(source) ? source(model) : source
|
||||
const categoryConfigs: Record<string, CategoryConfig> = categories
|
||||
@@ -81,7 +82,7 @@ export function buildAgent(
|
||||
}
|
||||
|
||||
if (agentWithCategory.skills?.length) {
|
||||
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider })
|
||||
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
|
||||
if (resolved.size > 0) {
|
||||
const skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
|
||||
@@ -207,7 +208,8 @@ function mergeAgentConfig(
|
||||
base: AgentConfig,
|
||||
override: AgentOverrideConfig
|
||||
): AgentConfig {
|
||||
const { prompt_append, ...rest } = override
|
||||
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
|
||||
const { prompt_append, ...rest } = migratedOverride
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
@@ -233,7 +235,8 @@ export async function createBuiltinAgents(
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string
|
||||
uiSelectedModel?: string,
|
||||
disabledSkills?: Set<string>
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
|
||||
@@ -257,7 +260,7 @@ export async function createBuiltinAgents(
|
||||
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
|
||||
}))
|
||||
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider })
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
|
||||
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
|
||||
|
||||
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
|
||||
@@ -290,16 +293,16 @@ export async function createBuiltinAgents(
|
||||
const override = agentOverrides[agentName]
|
||||
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
|
||||
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
|
||||
|
||||
|
||||
// Check if agent requires a specific model
|
||||
if (requirement?.requiresModel && availableModels) {
|
||||
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
|
||||
|
||||
|
||||
const resolution = applyModelResolution({
|
||||
uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined,
|
||||
userModel: override?.model,
|
||||
@@ -310,7 +313,7 @@ export async function createBuiltinAgents(
|
||||
if (!resolution) continue
|
||||
const { model, variant: resolvedVariant } = resolution
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
|
||||
|
||||
// Apply resolved variant from model fallback chain
|
||||
if (resolvedVariant) {
|
||||
@@ -374,7 +377,7 @@ export async function createBuiltinAgents(
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
|
||||
if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
@@ -391,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,
|
||||
@@ -419,7 +422,7 @@ export async function createBuiltinAgents(
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
|
||||
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
|
||||
|
||||
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
@@ -467,7 +470,7 @@ export async function createBuiltinAgents(
|
||||
availableSkills,
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
|
||||
if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
@@ -335,18 +335,18 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -355,14 +355,14 @@ exports[`generateModelConfig single native provider uses Gemini models when only
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "google/gemini-3-flash",
|
||||
@@ -395,18 +395,18 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
},
|
||||
"metis": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"momus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"multimodal-looker": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"prometheus": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -415,14 +415,14 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "google/gemini-3-flash",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "google/gemini-3-pro",
|
||||
@@ -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,28 +473,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,32 +546,32 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,28 +620,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,32 +693,32 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,28 +767,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "max",
|
||||
"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,32 +840,32 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "max",
|
||||
"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,28 +1024,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "opencode/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,28 +1097,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "max",
|
||||
"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": {
|
||||
@@ -1225,28 +1225,28 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
|
||||
},
|
||||
"oracle": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"quick": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
},
|
||||
"ultrabrain": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"variant": "high",
|
||||
},
|
||||
"unspecified-high": {
|
||||
"model": "anthropic/claude-sonnet-4-5",
|
||||
@@ -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,28 +1297,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "github-copilot/gemini-3-pro-preview",
|
||||
"variant": "max",
|
||||
"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,28 +1370,28 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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,32 +1443,32 @@ 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",
|
||||
},
|
||||
},
|
||||
"categories": {
|
||||
"artistry": {
|
||||
"model": "google/gemini-3-pro",
|
||||
"variant": "max",
|
||||
"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
|
||||
}
|
||||
@@ -69,8 +71,8 @@ export interface ModelResolutionInfo {
|
||||
}
|
||||
|
||||
interface OmoConfig {
|
||||
agents?: Record<string, { model?: string }>
|
||||
categories?: Record<string, { model?: string }>
|
||||
agents?: Record<string, { model?: string; variant?: string; category?: string }>
|
||||
categories?: Record<string, { model?: string; variant?: string }>
|
||||
}
|
||||
|
||||
function loadConfig(): OmoConfig | null {
|
||||
@@ -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),
|
||||
}
|
||||
@@ -182,7 +188,44 @@ function formatModelWithVariant(model: string, variant?: string): string {
|
||||
return variant ? `${model} (${variant})` : model
|
||||
}
|
||||
|
||||
function getEffectiveVariant(requirement: ModelRequirement): string | undefined {
|
||||
function getAgentOverride(
|
||||
agentName: string,
|
||||
config: OmoConfig,
|
||||
): { variant?: string; category?: string } | undefined {
|
||||
const agentOverrides = config.agents
|
||||
if (!agentOverrides) return undefined
|
||||
|
||||
// Direct lookup first, then case-insensitive lookup (matches agent-variant.ts)
|
||||
return (
|
||||
agentOverrides[agentName] ??
|
||||
Object.entries(agentOverrides).find(
|
||||
([key]) => key.toLowerCase() === agentName.toLowerCase()
|
||||
)?.[1]
|
||||
)
|
||||
}
|
||||
|
||||
function getEffectiveVariant(
|
||||
name: string,
|
||||
requirement: ModelRequirement,
|
||||
config: OmoConfig,
|
||||
): string | undefined {
|
||||
const agentOverride = getAgentOverride(name, config)
|
||||
|
||||
// Priority 1: Agent's direct variant override
|
||||
if (agentOverride?.variant) {
|
||||
return agentOverride.variant
|
||||
}
|
||||
|
||||
// Priority 2: Agent's category -> category's variant (matches agent-variant.ts)
|
||||
const categoryName = agentOverride?.category
|
||||
if (categoryName) {
|
||||
const categoryVariant = config.categories?.[categoryName]?.variant
|
||||
if (categoryVariant) {
|
||||
return categoryVariant
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: Fall back to requirement's fallback chain
|
||||
const firstEntry = requirement.fallbackChain[0]
|
||||
return firstEntry?.variant ?? requirement.variant
|
||||
}
|
||||
@@ -193,7 +236,20 @@ interface AvailableModelsInfo {
|
||||
cacheExists: boolean
|
||||
}
|
||||
|
||||
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo): string[] {
|
||||
function getCategoryEffectiveVariant(
|
||||
categoryName: string,
|
||||
requirement: ModelRequirement,
|
||||
config: OmoConfig,
|
||||
): string | undefined {
|
||||
const categoryVariant = config.categories?.[categoryName]?.variant
|
||||
if (categoryVariant) {
|
||||
return categoryVariant
|
||||
}
|
||||
const firstEntry = requirement.fallbackChain[0]
|
||||
return firstEntry?.variant ?? requirement.variant
|
||||
}
|
||||
|
||||
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] {
|
||||
const details: string[] = []
|
||||
|
||||
details.push("═══ Available Models (from cache) ═══")
|
||||
@@ -215,14 +271,17 @@ function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModels
|
||||
details.push("Agents:")
|
||||
for (const agent of info.agents) {
|
||||
const marker = agent.userOverride ? "●" : "○"
|
||||
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.requirement))
|
||||
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config))
|
||||
details.push(` ${marker} ${agent.name}: ${display}`)
|
||||
}
|
||||
details.push("")
|
||||
details.push("Categories:")
|
||||
for (const category of info.categories) {
|
||||
const marker = category.userOverride ? "●" : "○"
|
||||
const display = formatModelWithVariant(category.effectiveModel, getEffectiveVariant(category.requirement))
|
||||
const display = formatModelWithVariant(
|
||||
category.effectiveModel,
|
||||
getCategoryEffectiveVariant(category.name, category.requirement, config)
|
||||
)
|
||||
details.push(` ${marker} ${category.name}: ${display}`)
|
||||
}
|
||||
details.push("")
|
||||
@@ -249,7 +308,7 @@ export async function checkModelResolution(): Promise<CheckResult> {
|
||||
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
|
||||
status: available.cacheExists ? "pass" : "warn",
|
||||
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
|
||||
details: buildDetailsArray(info, available),
|
||||
details: buildDetailsArray(info, available, config),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
@@ -340,6 +351,17 @@ export const BrowserAutomationConfigSchema = z.object({
|
||||
provider: BrowserAutomationProviderSchema.default("playwright"),
|
||||
})
|
||||
|
||||
export const WebsearchProviderSchema = z.enum(["exa", "tavily"])
|
||||
|
||||
export const WebsearchConfigSchema = z.object({
|
||||
/**
|
||||
* Websearch provider to use.
|
||||
* - "exa": Uses Exa websearch (default, works without API key)
|
||||
* - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY)
|
||||
*/
|
||||
provider: WebsearchProviderSchema.optional(),
|
||||
})
|
||||
|
||||
export const TmuxLayoutSchema = z.enum([
|
||||
'main-horizontal', // main pane top, agent panes bottom stack
|
||||
'main-vertical', // main pane left, agent panes right stack (default)
|
||||
@@ -357,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),
|
||||
})
|
||||
@@ -393,6 +417,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
babysitting: BabysittingConfigSchema.optional(),
|
||||
git_master: GitMasterConfigSchema.optional(),
|
||||
browser_automation_engine: BrowserAutomationConfigSchema.optional(),
|
||||
websearch: WebsearchConfigSchema.optional(),
|
||||
tmux: TmuxConfigSchema.optional(),
|
||||
sisyphus: SisyphusConfigSchema.optional(),
|
||||
})
|
||||
@@ -420,6 +445,8 @@ export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
|
||||
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
|
||||
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
|
||||
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
|
||||
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
|
||||
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
|
||||
export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
|
||||
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
|
||||
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -351,6 +351,11 @@ export class BackgroundManager {
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Abort the session to prevent infinite polling hang
|
||||
this.client.session.abort({
|
||||
path: { id: sessionID },
|
||||
}).catch(() => {})
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
@@ -600,6 +605,14 @@ export class BackgroundManager {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
// Abort the session to prevent infinite polling hang
|
||||
if (existingTask.sessionID) {
|
||||
this.client.session.abort({
|
||||
path: { id: existingTask.sessionID },
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
@@ -1110,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
|
||||
@@ -1141,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)
|
||||
}
|
||||
|
||||
@@ -1179,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 {
|
||||
|
||||
@@ -86,4 +86,58 @@ describe("createBuiltinSkills", () => {
|
||||
expect(defaultSkills).toHaveLength(4)
|
||||
expect(agentBrowserSkills).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("should exclude playwright when it is in disabledSkills", () => {
|
||||
// #given
|
||||
const options = { disabledSkills: new Set(["playwright"]) }
|
||||
|
||||
// #when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
expect(skills.map((s) => s.name)).not.toContain("playwright")
|
||||
expect(skills.map((s) => s.name)).toContain("frontend-ui-ux")
|
||||
expect(skills.map((s) => s.name)).toContain("git-master")
|
||||
expect(skills.map((s) => s.name)).toContain("dev-browser")
|
||||
expect(skills.length).toBe(3)
|
||||
})
|
||||
|
||||
test("should exclude multiple skills when they are in disabledSkills", () => {
|
||||
// #given
|
||||
const options = { disabledSkills: new Set(["playwright", "git-master"]) }
|
||||
|
||||
// #when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
expect(skills.map((s) => s.name)).not.toContain("playwright")
|
||||
expect(skills.map((s) => s.name)).not.toContain("git-master")
|
||||
expect(skills.map((s) => s.name)).toContain("frontend-ui-ux")
|
||||
expect(skills.map((s) => s.name)).toContain("dev-browser")
|
||||
expect(skills.length).toBe(2)
|
||||
})
|
||||
|
||||
test("should return an empty array when all skills are disabled", () => {
|
||||
// #given
|
||||
const options = {
|
||||
disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "dev-browser"]),
|
||||
}
|
||||
|
||||
// #when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should return all skills when disabledSkills set is empty", () => {
|
||||
// #given
|
||||
const options = { disabledSkills: new Set<string>() }
|
||||
|
||||
// #when
|
||||
const skills = createBuiltinSkills(options)
|
||||
|
||||
// #then
|
||||
expect(skills.length).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,12 +11,19 @@ import {
|
||||
|
||||
export interface CreateBuiltinSkillsOptions {
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
disabledSkills?: Set<string>
|
||||
}
|
||||
|
||||
export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] {
|
||||
const { browserProvider = "playwright" } = options
|
||||
const { browserProvider = "playwright", disabledSkills } = options
|
||||
|
||||
const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill
|
||||
|
||||
return [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill]
|
||||
const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill]
|
||||
|
||||
if (!disabledSkills) {
|
||||
return skills
|
||||
}
|
||||
|
||||
return skills.filter((skill) => !disabledSkills.has(skill.name))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -387,4 +387,177 @@ Skill body.
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
---
|
||||
opencode-project body.
|
||||
`
|
||||
)
|
||||
|
||||
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")
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = await discoverSkills()
|
||||
const duplicates = skills.filter(s => s.name === "duplicate-skill")
|
||||
|
||||
// then
|
||||
expect(duplicates).toHaveLength(1)
|
||||
expect(duplicates[0]?.scope).toBe("opencode-project")
|
||||
expect(duplicates[0]?.definition.description).toContain("opencode-project")
|
||||
} 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("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()
|
||||
const matches = skills.filter(s => s.name === "global-over-project")
|
||||
|
||||
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("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
|
||||
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
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -66,7 +66,8 @@ async function loadSkillFromPath(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
scope: SkillScope,
|
||||
namePrefix: string = ""
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await fs.readFile(skillPath, "utf-8")
|
||||
@@ -75,7 +76,10 @@ async function loadSkillFromPath(
|
||||
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
// For nested skills, use the full path as the name (e.g., "superpowers/brainstorming")
|
||||
// For flat skills, use frontmatter name or directory name
|
||||
const baseName = data.name || defaultName
|
||||
const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
@@ -128,48 +132,67 @@ $ARGUMENTS
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
async function loadSkillsFromDir(
|
||||
skillsDir: string,
|
||||
scope: SkillScope,
|
||||
namePrefix: string = "",
|
||||
depth: number = 0,
|
||||
maxDepth: number = 2
|
||||
): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const directories = entries.filter(e => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink()))
|
||||
const files = entries.filter(e => !e.name.startsWith(".") && !e.isDirectory() && !e.isSymbolicLink() && isMarkdownFile(e))
|
||||
|
||||
for (const entry of directories) {
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
const resolvedPath = await resolveSymlinkAsync(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = await resolveSymlinkAsync(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
if (depth < maxDepth) {
|
||||
const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName
|
||||
const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth)
|
||||
for (const nestedSkill of nestedSkills) {
|
||||
if (!skillMap.has(nestedSkill.name)) {
|
||||
skillMap.set(nestedSkill.name, nestedSkill)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
for (const entry of files) {
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
const baseName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix)
|
||||
if (skill && !skillMap.has(skill.name)) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values())
|
||||
}
|
||||
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
@@ -210,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[]> {
|
||||
@@ -230,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([
|
||||
@@ -238,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> {
|
||||
|
||||
@@ -1,6 +1,34 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
|
||||
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
let testConfigDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
||||
}
|
||||
const unique = `skill-content-test-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
testConfigDir = join(tmpdir(), unique)
|
||||
process.env.CLAUDE_CONFIG_DIR = testConfigDir
|
||||
process.env.OPENCODE_CONFIG_DIR = testConfigDir
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("resolveSkillContent", () => {
|
||||
it("should return template for existing skill", () => {
|
||||
// given: builtin skills with 'frontend-ui-ux' skill
|
||||
@@ -33,10 +61,12 @@ describe("resolveSkillContent", () => {
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty string", () => {
|
||||
// given: builtin skills
|
||||
// when: resolving content for empty string
|
||||
const result = resolveSkillContent("")
|
||||
it("should return null for disabled skill", () => {
|
||||
// given: frontend-ui-ux skill disabled
|
||||
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
|
||||
|
||||
// when: resolving content for disabled skill
|
||||
const result = resolveSkillContent("frontend-ui-ux", options)
|
||||
|
||||
// then: returns null
|
||||
expect(result).toBeNull()
|
||||
@@ -96,6 +126,20 @@ describe("resolveMultipleSkills", () => {
|
||||
expect(result.notFound).toEqual(["skill-one", "skill-two", "skill-three"])
|
||||
})
|
||||
|
||||
it("should treat disabled skills as not found", () => {
|
||||
// #given: frontend-ui-ux disabled, playwright not disabled
|
||||
const skillNames = ["frontend-ui-ux", "playwright"]
|
||||
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
|
||||
|
||||
// #when: resolving multiple skills with disabled one
|
||||
const result = resolveMultipleSkills(skillNames, options)
|
||||
|
||||
// #then: frontend-ui-ux in notFound, playwright resolved
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.resolved.has("playwright")).toBe(true)
|
||||
expect(result.notFound).toEqual(["frontend-ui-ux"])
|
||||
})
|
||||
|
||||
it("should preserve skill order in resolved map", () => {
|
||||
// given: list of skill names in specific order
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
@@ -111,21 +155,24 @@ describe("resolveMultipleSkills", () => {
|
||||
})
|
||||
|
||||
describe("resolveSkillContentAsync", () => {
|
||||
it("should return template for builtin skill", async () => {
|
||||
it("should return template for builtin skill async", async () => {
|
||||
// given: builtin skill 'frontend-ui-ux'
|
||||
// when: resolving content async
|
||||
const result = await resolveSkillContentAsync("frontend-ui-ux")
|
||||
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
|
||||
const result = await resolveSkillContentAsync("git-master", options)
|
||||
|
||||
// then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Role: Designer-Turned-Developer")
|
||||
expect(result).toContain("Git Master Agent")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", async () => {
|
||||
// given: non-existent skill name
|
||||
// when: resolving content async
|
||||
const result = await resolveSkillContentAsync("definitely-not-a-skill-12345")
|
||||
it("should return null for disabled skill async", async () => {
|
||||
// given: frontend-ui-ux disabled
|
||||
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
|
||||
|
||||
// when: resolving content async for disabled skill
|
||||
const result = await resolveSkillContentAsync("frontend-ui-ux", options)
|
||||
|
||||
// then: returns null
|
||||
expect(result).toBeNull()
|
||||
@@ -133,9 +180,9 @@ describe("resolveSkillContentAsync", () => {
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkillsAsync", () => {
|
||||
it("should resolve builtin skills", async () => {
|
||||
it("should resolve builtin skills async", async () => {
|
||||
// given: builtin skill names
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
const skillNames = ["playwright", "git-master"]
|
||||
|
||||
// when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
@@ -144,10 +191,10 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual([])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer")
|
||||
expect(result.resolved.get("git-master")).toContain("Git Master Agent")
|
||||
})
|
||||
|
||||
it("should handle partial success with non-existent skills", async () => {
|
||||
it("should handle partial success with non-existent skills async", async () => {
|
||||
// given: mix of existing and non-existing skills
|
||||
const skillNames = ["playwright", "nonexistent-skill-12345"]
|
||||
|
||||
@@ -160,6 +207,20 @@ describe("resolveMultipleSkillsAsync", () => {
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
})
|
||||
|
||||
it("should treat disabled skills as not found async", async () => {
|
||||
// #given: frontend-ui-ux disabled
|
||||
const skillNames = ["frontend-ui-ux", "playwright"]
|
||||
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
|
||||
|
||||
// #when: resolving multiple skills async with disabled one
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: frontend-ui-ux in notFound, playwright resolved
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.resolved.has("playwright")).toBe(true)
|
||||
expect(result.notFound).toEqual(["frontend-ui-ux"])
|
||||
})
|
||||
|
||||
it("should NOT inject watermark when both options are disabled", async () => {
|
||||
// given: git-master skill with watermark disabled
|
||||
const skillNames = ["git-master"]
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/sc
|
||||
export interface SkillResolutionOptions {
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
disabledSkills?: Set<string>
|
||||
}
|
||||
|
||||
const cachedSkillsByProvider = new Map<string, LoadedSkill[]>()
|
||||
@@ -18,12 +19,22 @@ function clearSkillCache(): void {
|
||||
|
||||
async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
|
||||
const cacheKey = options?.browserProvider ?? "playwright"
|
||||
const cached = cachedSkillsByProvider.get(cacheKey)
|
||||
if (cached) return cached
|
||||
const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0
|
||||
|
||||
// Skip cache if disabledSkills is provided (varies between calls)
|
||||
if (!hasDisabledSkills) {
|
||||
const cached = cachedSkillsByProvider.get(cacheKey)
|
||||
if (cached) return cached
|
||||
}
|
||||
|
||||
const [discoveredSkills, builtinSkillDefs] = await Promise.all([
|
||||
discoverSkills({ includeClaudeCodePaths: true }),
|
||||
Promise.resolve(createBuiltinSkills({ browserProvider: options?.browserProvider })),
|
||||
Promise.resolve(
|
||||
createBuiltinSkills({
|
||||
browserProvider: options?.browserProvider,
|
||||
disabledSkills: options?.disabledSkills,
|
||||
})
|
||||
),
|
||||
])
|
||||
|
||||
const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({
|
||||
@@ -47,8 +58,15 @@ async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSki
|
||||
const discoveredNames = new Set(discoveredSkills.map((s) => s.name))
|
||||
const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name))
|
||||
|
||||
const allSkills = [...discoveredSkills, ...uniqueBuiltins]
|
||||
cachedSkillsByProvider.set(cacheKey, allSkills)
|
||||
let allSkills = [...discoveredSkills, ...uniqueBuiltins]
|
||||
|
||||
// Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills)
|
||||
if (hasDisabledSkills) {
|
||||
allSkills = allSkills.filter((s) => !options!.disabledSkills!.has(s.name))
|
||||
} else {
|
||||
cachedSkillsByProvider.set(cacheKey, allSkills)
|
||||
}
|
||||
|
||||
return allSkills
|
||||
}
|
||||
|
||||
@@ -122,7 +140,10 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
|
||||
}
|
||||
|
||||
export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null {
|
||||
const skills = createBuiltinSkills({ browserProvider: options?.browserProvider })
|
||||
const skills = createBuiltinSkills({
|
||||
browserProvider: options?.browserProvider,
|
||||
disabledSkills: options?.disabledSkills,
|
||||
})
|
||||
const skill = skills.find((s) => s.name === skillName)
|
||||
if (!skill) return null
|
||||
|
||||
@@ -137,7 +158,10 @@ export function resolveMultipleSkills(skillNames: string[], options?: SkillResol
|
||||
resolved: Map<string, string>
|
||||
notFound: string[]
|
||||
} {
|
||||
const skills = createBuiltinSkills({ browserProvider: options?.browserProvider })
|
||||
const skills = createBuiltinSkills({
|
||||
browserProvider: options?.browserProvider,
|
||||
disabledSkills: options?.disabledSkills,
|
||||
})
|
||||
const skillMap = new Map(skills.map((s) => [s.name, s.template]))
|
||||
|
||||
const resolved = new Map<string, string>()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +55,9 @@ export function getClaudeSettingsPaths(customPath?: string): string[] {
|
||||
paths.unshift(customPath)
|
||||
}
|
||||
|
||||
return paths
|
||||
// Deduplicate paths to prevent loading the same file multiple times
|
||||
// (e.g., when cwd is the home directory)
|
||||
return [...new Set(paths)]
|
||||
}
|
||||
|
||||
function mergeHooksConfig(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
const modelID = input.model?.modelID
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(cleanText), currentAgent, modelID)
|
||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ShellType } from "../../shared"
|
||||
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
|
||||
import { log, buildEnvPrefix } from "../../shared"
|
||||
|
||||
@@ -54,10 +53,8 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
// for git commands to prevent interactive prompts.
|
||||
|
||||
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
|
||||
// (via Git Bash, WSL, etc.), so we always use unix export syntax.
|
||||
// This fixes GitHub issues #983 and #889.
|
||||
const shellType: ShellType = "unix"
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
|
||||
// (via Git Bash, WSL, etc.), so always use unix export syntax.
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix")
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export const ALLOWED_EXTENSIONS = [".md"]
|
||||
|
||||
export const ALLOWED_PATH_PREFIX = ".sisyphus"
|
||||
|
||||
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit", "bash"]
|
||||
|
||||
export const PLANNING_CONSULT_WARNING = `
|
||||
|
||||
|
||||
@@ -173,7 +173,25 @@ describe("prometheus-md-only", () => {
|
||||
).rejects.toThrow("can only write/edit .md files")
|
||||
})
|
||||
|
||||
test("should not affect non-Write/Edit tools", async () => {
|
||||
test("should block bash commands from Prometheus", async () => {
|
||||
// given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "bash",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { command: "echo test" },
|
||||
}
|
||||
|
||||
// when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("cannot execute bash commands")
|
||||
})
|
||||
|
||||
test("should not affect non-blocked tools", async () => {
|
||||
// given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
@@ -334,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
|
||||
@@ -106,6 +130,20 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// Block bash commands completely - Prometheus is read-only
|
||||
if (toolName === "bash") {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (!filePath) {
|
||||
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)
|
||||
})
|
||||
})
|
||||
158
src/index.ts
158
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";
|
||||
@@ -98,7 +105,9 @@ import {
|
||||
getOpenCodeVersion,
|
||||
isOpenCodeVersionAtLeast,
|
||||
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";
|
||||
@@ -107,11 +116,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", {
|
||||
directory: ctx.directory,
|
||||
});
|
||||
injectServerAuthIntoClient(ctx.client);
|
||||
// Start background tmux check immediately
|
||||
startTmuxCheck();
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
|
||||
const firstMessageVariantGate = createFirstMessageVariantGate();
|
||||
|
||||
const tmuxConfig = {
|
||||
@@ -239,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, {
|
||||
@@ -278,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();
|
||||
|
||||
@@ -386,37 +398,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null;
|
||||
const browserProvider =
|
||||
pluginConfig.browser_automation_engine?.provider ?? "playwright";
|
||||
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,
|
||||
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 disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
|
||||
const systemMcpNames = getSystemMcpServerNames();
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider }).filter(
|
||||
(skill) => {
|
||||
if (disabledSkills.has(skill.name as never)) return false;
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => {
|
||||
if (skill.mcpConfig) {
|
||||
for (const mcpName of Object.keys(skill.mcpConfig)) {
|
||||
if (systemMcpNames.has(mcpName)) return false;
|
||||
@@ -441,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({
|
||||
@@ -448,6 +494,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
mcpManager: skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
gitMasterConfig: pluginConfig.git_master,
|
||||
disabledSkills
|
||||
});
|
||||
const skillMcpTool = createSkillMcpTool({
|
||||
manager: skillMcpManager,
|
||||
@@ -481,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) {
|
||||
@@ -718,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);
|
||||
@@ -835,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
|
||||
@@ -25,15 +25,24 @@ mcp/
|
||||
|
||||
| Name | URL | Purpose | Auth |
|
||||
|------|-----|---------|------|
|
||||
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY |
|
||||
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY |
|
||||
| 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 (optional) |
|
||||
| grep_app | mcp.grep.app | GitHub code search | None |
|
||||
|
||||
## THREE-TIER MCP SYSTEM
|
||||
## Websearch Provider Configuration
|
||||
|
||||
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)
|
||||
| Provider | URL | Auth | API Key Required |
|
||||
|----------|-----|------|------------------|
|
||||
| exa (default) | mcp.exa.ai | x-api-key header | No (optional) |
|
||||
| tavily | mcp.tavily.com | Authorization Bearer | Yes |
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"websearch": {
|
||||
"provider": "tavily" // or "exa" (default)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CONFIG PATTERN
|
||||
|
||||
@@ -47,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`
|
||||
@@ -66,5 +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
|
||||
|
||||
@@ -83,4 +83,24 @@ describe("createBuiltinMcps", () => {
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should not throw when websearch disabled even if tavily configured without API key", () => {
|
||||
// given
|
||||
const originalTavilyKey = process.env.TAVILY_API_KEY
|
||||
delete process.env.TAVILY_API_KEY
|
||||
const disabledMcps = ["websearch"]
|
||||
const config = { websearch: { provider: "tavily" as const } }
|
||||
|
||||
try {
|
||||
// when
|
||||
const createMcps = () => createBuiltinMcps(disabledMcps, config)
|
||||
|
||||
// then
|
||||
expect(createMcps).not.toThrow()
|
||||
const result = createMcps()
|
||||
expect(result).not.toHaveProperty("websearch")
|
||||
} finally {
|
||||
if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { websearch } from "./websearch"
|
||||
import { createWebsearchConfig } from "./websearch"
|
||||
import { context7 } from "./context7"
|
||||
import { grep_app } from "./grep-app"
|
||||
import type { McpName } from "./types"
|
||||
import type { OhMyOpenCodeConfig } from "../config/schema"
|
||||
|
||||
export { McpNameSchema, type McpName } from "./types"
|
||||
|
||||
@@ -13,19 +14,19 @@ type RemoteMcpConfig = {
|
||||
oauth?: false
|
||||
}
|
||||
|
||||
const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = {
|
||||
websearch,
|
||||
context7,
|
||||
grep_app,
|
||||
}
|
||||
|
||||
export function createBuiltinMcps(disabledMcps: string[] = []) {
|
||||
export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
|
||||
const mcps: Record<string, RemoteMcpConfig> = {}
|
||||
|
||||
for (const [name, config] of Object.entries(allBuiltinMcps)) {
|
||||
if (!disabledMcps.includes(name)) {
|
||||
mcps[name] = config
|
||||
}
|
||||
if (!disabledMcps.includes("websearch")) {
|
||||
mcps.websearch = createWebsearchConfig(config?.websearch)
|
||||
}
|
||||
|
||||
if (!disabledMcps.includes("context7")) {
|
||||
mcps.context7 = context7
|
||||
}
|
||||
|
||||
if (!disabledMcps.includes("grep_app")) {
|
||||
mcps.grep_app = grep_app
|
||||
}
|
||||
|
||||
return mcps
|
||||
|
||||
116
src/mcp/websearch.test.ts
Normal file
116
src/mcp/websearch.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { createWebsearchConfig } from "./websearch"
|
||||
|
||||
describe("websearch MCP provider configuration", () => {
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.EXA_API_KEY
|
||||
delete process.env.TAVILY_API_KEY
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
})
|
||||
|
||||
test("returns Exa config when no config provided", () => {
|
||||
//#given - no config
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig()
|
||||
|
||||
//#then
|
||||
expect(result.url).toContain("mcp.exa.ai")
|
||||
expect(result.type).toBe("remote")
|
||||
expect(result.enabled).toBe(true)
|
||||
})
|
||||
|
||||
test("returns Exa config when provider is 'exa'", () => {
|
||||
//#given
|
||||
const config = { provider: "exa" as const }
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig(config)
|
||||
|
||||
//#then
|
||||
expect(result.url).toContain("mcp.exa.ai")
|
||||
expect(result.type).toBe("remote")
|
||||
})
|
||||
|
||||
test("includes x-api-key header when EXA_API_KEY is set", () => {
|
||||
//#given
|
||||
const apiKey = "test-exa-key-12345"
|
||||
process.env.EXA_API_KEY = apiKey
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig()
|
||||
|
||||
//#then
|
||||
expect(result.headers).toEqual({ "x-api-key": apiKey })
|
||||
})
|
||||
|
||||
test("returns Tavily config when provider is 'tavily' and TAVILY_API_KEY set", () => {
|
||||
//#given
|
||||
const tavilyKey = "test-tavily-key-67890"
|
||||
process.env.TAVILY_API_KEY = tavilyKey
|
||||
const config = { provider: "tavily" as const }
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig(config)
|
||||
|
||||
//#then
|
||||
expect(result.url).toContain("mcp.tavily.com")
|
||||
expect(result.headers).toEqual({ Authorization: `Bearer ${tavilyKey}` })
|
||||
})
|
||||
|
||||
test("throws error when provider is 'tavily' but TAVILY_API_KEY missing", () => {
|
||||
//#given
|
||||
delete process.env.TAVILY_API_KEY
|
||||
const config = { provider: "tavily" as const }
|
||||
|
||||
//#when
|
||||
const createTavilyConfig = () => createWebsearchConfig(config)
|
||||
|
||||
//#then
|
||||
expect(createTavilyConfig).toThrow("TAVILY_API_KEY environment variable is required")
|
||||
})
|
||||
|
||||
test("returns Exa when both keys present but no explicit provider", () => {
|
||||
//#given
|
||||
process.env.EXA_API_KEY = "test-exa-key"
|
||||
process.env.TAVILY_API_KEY = "test-tavily-key"
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig()
|
||||
|
||||
//#then
|
||||
expect(result.url).toContain("mcp.exa.ai")
|
||||
expect(result.headers).toEqual({ "x-api-key": "test-exa-key" })
|
||||
})
|
||||
|
||||
test("Tavily config uses Authorization Bearer header format", () => {
|
||||
//#given
|
||||
const tavilyKey = "tavily-secret-key-xyz"
|
||||
process.env.TAVILY_API_KEY = tavilyKey
|
||||
const config = { provider: "tavily" as const }
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig(config)
|
||||
|
||||
//#then
|
||||
expect(result.headers?.Authorization).toMatch(/^Bearer /)
|
||||
expect(result.headers?.Authorization).toBe(`Bearer ${tavilyKey}`)
|
||||
})
|
||||
|
||||
test("Exa config has no headers when EXA_API_KEY not set", () => {
|
||||
//#given
|
||||
delete process.env.EXA_API_KEY
|
||||
|
||||
//#when
|
||||
const result = createWebsearchConfig()
|
||||
|
||||
//#then
|
||||
expect(result.url).toContain("mcp.exa.ai")
|
||||
expect(result.headers).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,44 @@
|
||||
export const websearch = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
|
||||
enabled: true,
|
||||
headers: process.env.EXA_API_KEY
|
||||
? { "x-api-key": process.env.EXA_API_KEY }
|
||||
: undefined,
|
||||
// Disable OAuth auto-detection - Exa uses API key header, not OAuth
|
||||
oauth: false as const,
|
||||
import type { WebsearchConfig } from "../config/schema"
|
||||
|
||||
type RemoteMcpConfig = {
|
||||
type: "remote"
|
||||
url: string
|
||||
enabled: boolean
|
||||
headers?: Record<string, string>
|
||||
oauth?: false
|
||||
}
|
||||
|
||||
export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig {
|
||||
const provider = config?.provider || "exa"
|
||||
|
||||
if (provider === "tavily") {
|
||||
const tavilyKey = process.env.TAVILY_API_KEY
|
||||
if (!tavilyKey) {
|
||||
throw new Error("TAVILY_API_KEY environment variable is required for Tavily provider")
|
||||
}
|
||||
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.tavily.com/mcp/",
|
||||
enabled: true,
|
||||
headers: {
|
||||
Authorization: `Bearer ${tavilyKey}`,
|
||||
},
|
||||
oauth: false as const,
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Exa
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
|
||||
enabled: true,
|
||||
headers: process.env.EXA_API_KEY
|
||||
? { "x-api-key": process.env.EXA_API_KEY }
|
||||
: undefined,
|
||||
oauth: false as const,
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: export static instance using default config
|
||||
export const websearch = createWebsearchConfig()
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -157,6 +157,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
// config.model represents the currently active model in OpenCode (including UI selection)
|
||||
// Pass it as uiSelectedModel so it takes highest priority in model resolution
|
||||
const currentModel = config.model as string | undefined;
|
||||
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
|
||||
const builtinAgents = await createBuiltinAgents(
|
||||
migratedDisabledAgents,
|
||||
pluginConfig.agents,
|
||||
@@ -167,7 +168,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
allDiscoveredSkills,
|
||||
ctx.client,
|
||||
browserProvider,
|
||||
currentModel // uiSelectedModel - takes highest priority
|
||||
currentModel, // uiSelectedModel - takes highest priority
|
||||
disabledSkills
|
||||
);
|
||||
|
||||
// Claude Code agents: Do NOT apply permission migration
|
||||
@@ -220,7 +222,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
|
||||
agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["sisyphus-junior"],
|
||||
config.model as string | undefined
|
||||
undefined
|
||||
);
|
||||
|
||||
if (builderEnabled) {
|
||||
@@ -305,7 +307,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
prompt: PROMETHEUS_SYSTEM_PROMPT,
|
||||
permission: PROMETHEUS_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
|
||||
color: (configAgent?.plan?.color as string) ?? "#9D4EDD", // Amethyst Purple - wisdom/foresight
|
||||
color: (configAgent?.plan?.color as string) ?? "#FF5722", // Deep Orange - Fire/Flame theme
|
||||
...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}),
|
||||
...(topPToUse !== undefined ? { top_p: topPToUse } : {}),
|
||||
...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}),
|
||||
@@ -358,7 +360,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = shouldDemotePlan
|
||||
? { mode: "subagent" as const }
|
||||
? { mode: "subagent" as const
|
||||
}
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
@@ -447,7 +450,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig),
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user