Compare commits
104 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ed031db63 | ||
|
|
0553676ab0 | ||
|
|
b80b373230 | ||
|
|
f55046228f | ||
|
|
2992902283 | ||
|
|
b66c8dc1d1 | ||
|
|
8f2209a138 | ||
|
|
6c3ef65aed | ||
|
|
e1e8b24941 | ||
|
|
0d0ddefbfe | ||
|
|
09f72e2902 | ||
|
|
5f63aff01d | ||
|
|
6fd9734337 | ||
|
|
4bf853fc91 | ||
|
|
87134d3390 | ||
|
|
36c42ac92f | ||
|
|
56fe32caab | ||
|
|
09756b8ffc | ||
|
|
9ba9f906c5 | ||
|
|
ce69007fde | ||
|
|
b1f36d61a8 | ||
|
|
97e51c42dc | ||
|
|
91d2705804 | ||
|
|
6575dfcbc4 | ||
|
|
59b0e6943d | ||
|
|
9d64f213ee | ||
|
|
e572c7c321 | ||
|
|
be2adff3ef | ||
|
|
37f4c48183 | ||
|
|
a49fbeec5f | ||
|
|
7a7b16fb62 | ||
|
|
ae781f1e14 | ||
|
|
d7645a4058 | ||
|
|
16927729c7 | ||
|
|
a4ba63cd1c | ||
|
|
063db0d390 | ||
|
|
dc52395ead | ||
|
|
c8e9f90900 | ||
|
|
6fbc5ba582 | ||
|
|
fc76ea9d93 | ||
|
|
2a3b45bea5 | ||
|
|
79b80e5a2f | ||
|
|
e2cbe8c29b | ||
|
|
99c7df5640 | ||
|
|
f61e1a5f2b | ||
|
|
03c51c9321 | ||
|
|
c10994563b | ||
|
|
d188688dd8 | ||
|
|
95645effd7 | ||
|
|
00b8f622d5 | ||
|
|
967e53258c | ||
|
|
c40f562434 | ||
|
|
a9523bc607 | ||
|
|
f26bf24c33 | ||
|
|
bc65fcea7e | ||
|
|
3a8eac751e | ||
|
|
48dc8298dd | ||
|
|
8bc9d6a540 | ||
|
|
6a6e20cf5d | ||
|
|
3a5aea7f4b | ||
|
|
a4812801b4 | ||
|
|
6422ff270b | ||
|
|
3c27206777 | ||
|
|
8510a2273d | ||
|
|
a8ca3ad5fb | ||
|
|
30e0cc6ef1 | ||
|
|
f345101f91 | ||
|
|
d09c994b91 | ||
|
|
8c30974c18 | ||
|
|
c341c156ec | ||
|
|
b1528c590d | ||
|
|
8b9913345b | ||
|
|
fa204d8af0 | ||
|
|
924fa79bd3 | ||
|
|
c78241e78e | ||
|
|
d0694e5aa4 | ||
|
|
4a9bdc89aa | ||
|
|
50afbf7c37 | ||
|
|
b64b3f96e6 | ||
|
|
e3ad790185 | ||
|
|
8d570af3dd | ||
|
|
ddeabb1a8b | ||
|
|
7a896fd2b9 | ||
|
|
823f12d88d | ||
|
|
bf3dd91da2 | ||
|
|
fd957e7ed0 | ||
|
|
3ba61790ab | ||
|
|
3224c15578 | ||
|
|
a51ad98182 | ||
|
|
b98a1b28f8 | ||
|
|
9a92dc8d95 | ||
|
|
99711dacc1 | ||
|
|
6eaa96f421 | ||
|
|
f6b066ecfa | ||
|
|
4434a59cf0 | ||
|
|
038d838e63 | ||
|
|
dc057e9910 | ||
|
|
d4787c477a | ||
|
|
e6ffdc4352 | ||
|
|
a1fe0f8517 | ||
|
|
bebe6607d4 | ||
|
|
f088f008cc | ||
|
|
f64210c505 | ||
|
|
b75383fb99 |
22
.github/workflows/lint-workflows.yml
vendored
Normal file
22
.github/workflows/lint-workflows.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Lint Workflows
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.10/scripts/download-actionlint.bash)
|
||||
|
||||
- name: Run actionlint
|
||||
run: ./actionlint -color -shellcheck=""
|
||||
138
.github/workflows/sisyphus-agent.yml
vendored
138
.github/workflows/sisyphus-agent.yml
vendored
@@ -15,13 +15,13 @@ jobs:
|
||||
agent:
|
||||
runs-on: ubuntu-latest
|
||||
# @sisyphus-dev-ai mention only (maintainers, exclude self)
|
||||
if: |
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
|
||||
github.event.comment.user.login != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body || '', '@sisyphus-dev-ai') &&
|
||||
(github.event.comment.user.login || '') != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || ''))
|
||||
|
||||
# Minimal default GITHUB_TOKEN permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -156,6 +156,71 @@ jobs:
|
||||
|
||||
OMO_JSON=~/.config/opencode/oh-my-opencode.json
|
||||
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
|
||||
<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: For implementation tasks, spawn a dedicated planning agent for work breakdown (not needed for simple questions/investigations)
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
|
||||
1. Write spec (requirements)
|
||||
2. Write tests (failing)
|
||||
3. RED: tests fail
|
||||
4. Implement minimal code
|
||||
5. GREEN: tests pass
|
||||
6. Refactor if needed (must stay green)
|
||||
7. Next feature, repeat
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions Environment
|
||||
|
||||
@@ -244,14 +309,17 @@ jobs:
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
|
||||
# Check if PR or Issue
|
||||
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
|
||||
# Check if PR or Issue and get title
|
||||
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
|
||||
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
|
||||
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=issue" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "title=${TITLE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
@@ -297,15 +365,32 @@ jobs:
|
||||
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
|
||||
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
|
||||
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
|
||||
CONTEXT_TITLE: ${{ steps.context.outputs.title }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
PROMPT=$(cat <<'PROMPT_EOF'
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
|
||||
|
||||
## Context
|
||||
- Title: TITLE_PLACEHOLDER
|
||||
- Type: TYPE_PLACEHOLDER
|
||||
- Number: #NUMBER_PLACEHOLDER
|
||||
- Repository: REPO_PLACEHOLDER
|
||||
@@ -316,8 +401,42 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
Write everything using the todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
## CRITICAL: First Steps (MUST DO BEFORE ANYTHING ELSE)
|
||||
|
||||
### [CODE RED] MANDATORY CONTEXT READING - ZERO EXCEPTIONS
|
||||
|
||||
**YOU MUST READ ALL CONTENT. NOT SOME. NOT MOST. ALL.**
|
||||
|
||||
1. **READ FULL CONVERSATION** - Execute ALL commands below before ANY other action:
|
||||
- **Issues**: `gh issue view NUMBER_PLACEHOLDER --comments`
|
||||
- **PRs**: Use ALL THREE commands to get COMPLETE context:
|
||||
```bash
|
||||
gh pr view NUMBER_PLACEHOLDER --comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/reviews
|
||||
```
|
||||
|
||||
**WHAT TO EXTRACT FROM THE CONVERSATION:**
|
||||
- The ORIGINAL issue/PR description (first message) - this is often the TRUE requirement
|
||||
- ALL previous attempts and their outcomes
|
||||
- ALL decisions made and their reasoning
|
||||
- ALL feedback, criticism, and rejection reasons
|
||||
- ANY linked issues, PRs, or external references
|
||||
- The EXACT ask from the user who mentioned you
|
||||
|
||||
**FAILURE TO READ EVERYTHING = GUARANTEED FAILURE**
|
||||
You WILL make wrong assumptions. You WILL repeat past mistakes. You WILL miss critical context.
|
||||
|
||||
2. **CREATE TODOS IMMEDIATELY**: Right after reading, create your todo list using todo tools.
|
||||
- First todo: "Summarize issue/PR context and requirements"
|
||||
- Break down ALL work into atomic, verifiable steps
|
||||
- Plan everything BEFORE starting any work
|
||||
|
||||
---
|
||||
|
||||
|
||||
Plan everything using todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
|
||||
PROMPT_EOF
|
||||
)
|
||||
@@ -326,6 +445,7 @@ jobs:
|
||||
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
|
||||
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
|
||||
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
|
||||
PROMPT="${PROMPT//TITLE_PLACEHOLDER/$CONTEXT_TITLE}"
|
||||
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
|
||||
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# Dependencies
|
||||
.sisyphus/
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
|
||||
161
AGENTS.md
161
AGENTS.md
@@ -1,30 +1,29 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-02T00:10:00+09:00
|
||||
**Commit:** b0c39e2
|
||||
**Generated:** 2026-01-02T22:41:22+09:00
|
||||
**Commit:** d0694e5
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orchestration (GPT-5.2, Claude, Gemini, Grok), LSP tools (11), AST-Grep search, MCP integrations (context7, websearch_exa, grep_app). "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok), 11 LSP tools, AST-Grep, Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
|
||||
│ ├── agents/ # 7 AI agents - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 22 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
|
||||
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
|
||||
│ ├── features/ # Claude Code compatibility + core features - see src/features/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── tools/ # LSP, AST-Grep, session mgmt - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc. - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (464 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
|
||||
@@ -32,71 +31,67 @@ oh-my-opencode/
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts and types.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Create skill dir with SKILL.md |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google/Gemini models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents, update types.ts |
|
||||
| Add hook | `src/hooks/` | Dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with constants/types/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Dir with SKILL.md |
|
||||
| Config schema | `src/config/schema.ts` | Run `bun run build:schema` after |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
| Skill MCP | `src/features/skill-mcp-manager/` | MCP servers embedded in skills |
|
||||
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
|
||||
| CLI installer | `src/cli/install.ts` | Interactive TUI installation |
|
||||
| Doctor checks | `src/cli/doctor/checks/` | Health checks for environment |
|
||||
| Shared utilities | `src/shared/` | Cross-cutting utilities |
|
||||
| Slash commands | `src/hooks/auto-slash-command/` | Auto-detect and execute `/command` patterns |
|
||||
| Ralph Loop | `src/hooks/ralph-loop/` | Self-referential dev loop until completion |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
|
||||
|
||||
```
|
||||
1. RED - Write failing test first (test MUST fail)
|
||||
2. GREEN - Write MINIMAL code to pass (nothing more)
|
||||
3. REFACTOR - Clean up while tests stay GREEN
|
||||
4. REPEAT - Next test case
|
||||
```
|
||||
|
||||
| Phase | Action | Verification |
|
||||
|-------|--------|--------------|
|
||||
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
|
||||
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
|
||||
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
|
||||
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests to "pass" - fix the code
|
||||
- One test at a time - don't batch
|
||||
- Test file naming: `*.test.ts` alongside source
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
|
||||
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
|
||||
- **Types**: bun-types (not @types/node)
|
||||
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
|
||||
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
|
||||
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
|
||||
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
|
||||
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA); TDD workflow (RED-GREEN-REFACTOR)
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **npm/yarn**: Use bun exclusively
|
||||
- **@types/node**: Use bun-types
|
||||
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow
|
||||
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Over-exploration**: Stop searching when sufficient context found
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Sequential agent calls**: Use `background_task` for parallel execution
|
||||
- **Heavy PreToolUse logic**: Slows every tool call
|
||||
- **Self-planning for complex tasks**: Spawn planning agent (Prometheus) instead
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Optional props**: Extensive `?` for optional interface properties
|
||||
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
|
||||
- **Error handling**: Consistent try/catch with async/await
|
||||
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function convention
|
||||
- **Factory pattern**: Components created via `createXXX()` functions
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
|
||||
| Package Manager | npm, yarn, npx |
|
||||
| File Ops | Bash mkdir/touch/rm for code file creation |
|
||||
| Publishing | Direct `bun publish`, local version bump |
|
||||
| Agent Behavior | High temp (>0.3), broad tool access, sequential agent calls |
|
||||
| Hooks | Heavy PreToolUse logic, blocking without reason |
|
||||
| Year | 2024 in code/prompts (use current year) |
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
|
||||
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
|
||||
| explore | opencode/grok-code | Fast codebase exploration |
|
||||
| oracle | openai/gpt-5.2 | Strategy, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Docs, OSS research |
|
||||
| explore | opencode/grok-code | Fast codebase grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical docs |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
@@ -107,8 +102,7 @@ oh-my-opencode/
|
||||
bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun run build:schema # Schema only
|
||||
bun test # Run tests
|
||||
bun test # Run tests (380+)
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -116,37 +110,26 @@ bun test # Run tests
|
||||
**GitHub Actions workflow_dispatch only**
|
||||
|
||||
1. Never modify package.json version locally
|
||||
2. Commit & push changes
|
||||
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
|
||||
2. Commit & push to dev
|
||||
3. Trigger: `gh workflow run publish -f bump=patch|minor|major`
|
||||
|
||||
**Critical**: Never `bun publish` directly. Never bump version locally.
|
||||
|
||||
## CI PIPELINE
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
- **sisyphus-agent.yml**: Agent-in-CI for automated issue handling via `@sisyphus-dev-ai` mentions
|
||||
CI auto-commits schema changes on master, maintains rolling `next` draft release on dev.
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin orchestration, all hook/tool initialization |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, environment detection, installation |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting, endpoint fallbacks |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, stdin/stdout buffering, JSON-RPC |
|
||||
| `src/auth/antigravity/response.ts` | 598 | Response transformation, streaming |
|
||||
| `src/auth/antigravity/thinking.ts` | 571 | Thinking block extraction/transformation |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Session compaction, multi-stage recovery pipeline |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt, delegation strategies |
|
||||
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | Multi-stage recovery |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 360+ tests
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **JSONC support**: Config files support comments (`// comment`, `/* block */`) and trailing commas
|
||||
- **Claude Code Compat**: Full compatibility layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`
|
||||
- **JSONC**: Config files support comments and trailing commas
|
||||
- **Claude Code**: Full compat layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **Skill MCP**: Skills can embed MCP server configs in YAML frontmatter
|
||||
|
||||
49
README.ja.md
49
README.ja.md
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **オーケストレーターが来ます。今週中に。[Xで通知を受け取る](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 一緒に歩みましょう!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -46,6 +48,11 @@
|
||||
|
||||
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "Ohmyopencodeとralph loopを使って、一晩で45,000行のtauriアプリをSaaSウェブアプリに変換しました。インタビュープロンプトから始めて、質問に対する評価と推奨を求めました。作業する様子を見ているのは驚きでしたし、朝起きたらほぼ完成したウェブサイトがありました!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "今週末はopen code、oh my opencode、supermemoryでマインクラフト/ソウルライクな何かを作る実験をしています。"
|
||||
> "昼食後の散歩に行く間に、しゃがみアニメーションを追加するよう頼みました。[動画]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -56,6 +63,8 @@
|
||||
|
||||
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -70,6 +79,19 @@
|
||||
- [インストール](#インストール)
|
||||
- [人間の方へ](#人間の方へ)
|
||||
- [LLM エージェントの方へ](#llm-エージェントの方へ)
|
||||
- [ステップ 0: サブスクリプション情報を確認](#ステップ-0-サブスクリプション情報を確認)
|
||||
- [ステップ 1: OpenCode インストール確認](#ステップ-1-opencode-インストール確認)
|
||||
- [ステップ 2: インストーラーを実行](#ステップ-2-インストーラーを実行)
|
||||
- [ステップ 3: 設定確認](#ステップ-3-設定確認)
|
||||
- [ステップ 4: 認証情報の設定](#ステップ-4-認証情報の設定)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [セットアップの確認](#セットアップの確認)
|
||||
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
|
||||
- [複雑すぎますか?](#複雑すぎますか)
|
||||
- [アンインストール](#アンインストール)
|
||||
- [機能](#機能)
|
||||
- [Agents: あなたの新しいチームメイト](#agents-あなたの新しいチームメイト)
|
||||
- [バックグラウンドエージェント: 本当のチームのように働く](#バックグラウンドエージェント-本当のチームのように働く)
|
||||
@@ -128,6 +150,12 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
- なぜこれが良いのか、単なるボイラープレートではないのか、具体的に何が良くなるのか聞いてみてください。
|
||||
- もしあなたが LLM エージェントなら、このプロジェクトによって今すぐ何が可能になるのか、ユーザーに説明してください。
|
||||
|
||||
### 🪄 魔法の言葉:`ultrawork`
|
||||
|
||||
**これ全部読みたくない?プロンプトに `ultrawork`(または `ulw`)を入れるだけでOK。**
|
||||
|
||||
それだけです。私たちが提供するすべての機能が魔法のように動作します—並列エージェント、バックグラウンドタスク、深い探索、そして完了するまで止まらない実行。エージェントが自動的にすべてを処理します。
|
||||
|
||||
### 読みたい方のために:シジフォスに会う
|
||||
|
||||

|
||||
@@ -223,8 +251,12 @@ OpenCode がインストールされていない場合は、[OpenCode インス
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# bunx が動作しない場合は npx を使用
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian ユーザーへの注意**: Snap で Bun をインストールした場合 (`/snap/bin/bun`)、Snap のサンドボックス化により `bunx` が「script not found」エラーで失敗します。代わりに `npx` を使用するか、公式インストーラーで Bun を再インストールしてください: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**例:**
|
||||
- すべてのサブスクリプション + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- Claude のみ(max20 なし): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -816,7 +848,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
有効時(デフォルト)、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
|
||||
|
||||
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **OpenCode-Builder**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
|
||||
|
||||
**設定オプション:**
|
||||
@@ -832,7 +864,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
**例:Builder-Sisyphus を有効化:**
|
||||
**例:OpenCode-Builder を有効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -842,7 +874,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
これにより、Sisyphus と並行して OpenCode-Builder エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
|
||||
**例:すべての Sisyphus オーケストレーションを無効化:**
|
||||
|
||||
@@ -863,7 +895,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -876,7 +908,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化します(OpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
@@ -1015,5 +1047,8 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 最初のスポンサー
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 私のキャリアをスタートさせてくださった方であり、優れたエージェンティックワークフローをどのように構築できるかについて多大なインスピレーションを与えてくださった方です。優れたチームを作るために優れたシステムをどう設計すべきか多くのことを学び、その学びがこのharnessを作る上で大きな助けとなりました。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
|
||||
|
||||
49
README.ko.md
49
README.ko.md
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **오케스트레이터가 옵니다. 이번 주에요. [X에서 알림받기](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 함께해주세요!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discord 커뮤니티](https://discord.gg/PUwSMR9XNk)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -43,6 +45,11 @@
|
||||
|
||||
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "Ohmyopencode와 ralph loop을 사용해서, 하룻밤 만에 45,000줄짜리 tauri 앱을 SaaS 웹앱으로 전환했습니다. 인터뷰 프롬프트로 시작해서, 질문에 대한 평점과 추천을 요청했습니다. 일하는 모습을 지켜보는 것도 놀라웠고, 아침에 일어나니 거의 완성된 웹사이트가 있었습니다!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "이번 주말에 open code, oh my opencode와 supermemory로 마인크래프트/소울라이크 같은 괴물을 만들어보고 있습니다."
|
||||
> "점심 먹고 산책하러 가는 동안 웅크리기 애니메이션 추가해달라고 시켰습니다. [영상]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -53,6 +60,8 @@
|
||||
|
||||
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -67,6 +76,19 @@
|
||||
- [설치](#설치)
|
||||
- [인간인 당신을 위한 설치 가이드](#인간인-당신을-위한-설치-가이드)
|
||||
- [LLM Agent 를 위한 설치 가이드](#llm-agent-를-위한-설치-가이드)
|
||||
- [0단계: 구독 정보 확인](#0단계-구독-정보-확인)
|
||||
- [1단계: OpenCode 설치 확인](#1단계-opencode-설치-확인)
|
||||
- [2단계: 설치 프로그램 실행](#2단계-설치-프로그램-실행)
|
||||
- [3단계: 설정 확인](#3단계-설정-확인)
|
||||
- [4단계: 인증정보 설정](#4단계-인증정보-설정)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 주의](#️-주의)
|
||||
- [설정 확인](#설정-확인)
|
||||
- [사용자에게 '축하합니다! 🎉'라고 말하세요](#사용자에게-축하합니다-라고-말하세요)
|
||||
- [너무 복잡한가요?](#너무-복잡한가요)
|
||||
- [언인스톨](#언인스톨)
|
||||
- [기능](#기능)
|
||||
- [Agents: 당신의 새로운 팀원들](#agents-당신의-새로운-팀원들)
|
||||
- [백그라운드 에이전트: 진짜 팀 처럼 일 하도록](#백그라운드-에이전트-진짜-팀-처럼-일-하도록)
|
||||
@@ -127,6 +149,12 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
- 이게 왜 좋고, 그냥 뻔한 셋업이 아닌지, 당장 무엇이 더 좋아지는지 알려달라고 해보세요.
|
||||
- 당신이 LLM 에이전트라면, 사용자의 입장에서 이 프로젝트와 함께 당장 어떤것들이 가능해지는지 사용자에게 설명하세요.
|
||||
|
||||
### 🪄 마법의 단어: `ultrawork`
|
||||
|
||||
**이거 다 읽기 싫으시죠? 프롬프트에 `ultrawork` (또는 `ulw`) 만 넣으세요.**
|
||||
|
||||
그게 다입니다. 우리가 제공하는 모든 기능이 마법처럼 작동합니다—병렬 에이전트, 백그라운드 태스크, 깊은 탐색, 그리고 완료될 때까지 멈추지 않는 실행. 에이전트가 알아서 다 합니다.
|
||||
|
||||
### 하지만 읽고 싶은 당신을 위해: 시지푸스를 만나보세요
|
||||
|
||||

|
||||
@@ -220,8 +248,12 @@ OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://o
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# bunx가 작동하지 않으면 npx 사용
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian 사용자 참고**: Snap으로 Bun을 설치한 경우 (`/snap/bin/bun`), Snap의 샌드박싱으로 인해 `bunx`가 "script not found" 오류와 함께 실패합니다. 대신 `npx`를 사용하거나, 공식 설치 스크립트로 Bun을 재설치하세요: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**예시:**
|
||||
- 모든 구독 + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- Claude만 (max20 없음): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -809,7 +841,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
|
||||
|
||||
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **OpenCode-Builder**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
|
||||
|
||||
**설정 옵션:**
|
||||
@@ -825,7 +857,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
**예시: Builder-Sisyphus 활성화하기:**
|
||||
**예시: OpenCode-Builder 활성화하기:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -835,7 +867,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
이렇게 하면 Sisyphus와 함께 OpenCode-Builder 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
|
||||
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
|
||||
|
||||
@@ -856,7 +888,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -869,7 +901,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 OpenCode-Builder 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
|
||||
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
|
||||
|
||||
@@ -1008,5 +1040,8 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 첫 번째 스폰서
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **전수열 (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 저의 커리어 시작을 만들어주신분이며, 좋은 에이전틱 워크플로우를 어떻게 만들 수 있을까에 대해 많은 영감을 주신 분입니다. 좋은 팀을 만들기 위해 좋은 시스템을 어떻게 설계 할 수 있을지 많은 배움을 얻었고 그러한 내용들이 이 harness를 만드는데에 큰 도움이 되었습니다.
|
||||
- **원혜린 (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
|
||||
|
||||
55
README.md
55
README.md
@@ -7,10 +7,9 @@
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -54,6 +53,11 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "I converted a 45k line tauri app into a SaaS web app overnight using Ohmyopencode and ralph loop. Started with interview me prompt, asked it for ratings and recommendations on the questions. It was amazing to watch it work and to wake up this morning to a mostly working website!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "Experimenting with open code, oh my opencode and supermemory this weekend to build some minecraft/souls-like abomination."
|
||||
> "Asking it to add crouch animations while I go take my post-lunch walk. [Video]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
|
||||
|
||||
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -64,7 +68,9 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
>
|
||||
> "Isn't the name Sisyphus beautiful by itself?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
@@ -152,6 +158,12 @@ Hand this doc to an agent and let them set it up.
|
||||
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
|
||||
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
|
||||
|
||||
### 🪄 The Magic Word: `ultrawork`
|
||||
|
||||
**Don't want to read all this? Just include `ultrawork` (or `ulw`) in your prompt.**
|
||||
|
||||
That's it. All the features we provide will work like magic—parallel agents, background tasks, deep exploration, and relentless execution until completion. The agent figures out the rest automatically.
|
||||
|
||||
### For Those Who Want to Read: Meet Sisyphus
|
||||
|
||||

|
||||
@@ -204,8 +216,12 @@ Run the interactive installer:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# or use npx if bunx doesn't work
|
||||
npx oh-my-opencode install
|
||||
```
|
||||
|
||||
> **Note for Ubuntu/Debian users**: If you installed Bun via Snap (`/snap/bin/bun`), `bunx` will fail with "script not found" due to Snap's sandboxing. Either use `npx` instead, or reinstall Bun via the official installer: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
|
||||
|
||||
**Alternative: Let an LLM Agent do it**
|
||||
@@ -577,6 +593,26 @@ Instead of the agent reading massive files and bloating context, it internally l
|
||||
#### I Removed Their Blockers
|
||||
- Replaces built-in grep and glob tools. Default implementation has no timeout—can hang forever.
|
||||
|
||||
#### Skill-Embedded MCP Support
|
||||
|
||||
Skills can now bring their own MCP servers. Define MCP configurations directly in skill frontmatter or via `mcp.json` files:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: Browser automation skill
|
||||
mcp:
|
||||
playwright:
|
||||
command: npx
|
||||
args: ["-y", "@anthropic-ai/mcp-playwright"]
|
||||
---
|
||||
```
|
||||
|
||||
When you load a skill with embedded MCP, its tools become available automatically. The `skill_mcp` tool lets you invoke these MCP operations with full schema discovery.
|
||||
|
||||
**Built-in Skills:**
|
||||
- **playwright**: Browser automation, web scraping, testing, and screenshots out of the box
|
||||
|
||||
Disable built-in skills via `disabled_skills: ["playwright"]` in your config.
|
||||
|
||||
### Goodbye Claude Code. Hello Oh My OpenCode.
|
||||
|
||||
@@ -867,7 +903,7 @@ Available built-in skills: `playwright`
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **OpenCode-Builder**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
|
||||
|
||||
**Configuration Options:**
|
||||
@@ -883,7 +919,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Enable Builder-Sisyphus:**
|
||||
**Example: Enable OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -893,7 +929,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
This enables OpenCode-Builder agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
|
||||
**Example: Disable all Sisyphus orchestration:**
|
||||
|
||||
@@ -914,7 +950,7 @@ You can also customize Sisyphus agents like other agents:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -927,7 +963,7 @@ You can also customize Sisyphus agents like other agents:
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
|
||||
|
||||
@@ -1066,5 +1102,8 @@ I have no affiliation with any project or model mentioned here. This is purely p
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- The first sponsor
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- The person who launched my career and inspired me deeply on how to build great agentic workflows. I learned so much about designing great systems to build great teams, and those lessons were instrumental in creating this harness.
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *"我致力于引发一场软件革命,创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金,并将继续这样做。"*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **编排器即将到来。就在本周。[在X上获取通知](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 与我们同行!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -48,6 +50,11 @@
|
||||
|
||||
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "用Ohmyopencode和ralph loop,一夜之间把45,000行的tauri应用转成了SaaS网页应用。从面试提示开始,让它对问题进行评分和推荐。看着它工作真是太神奇了,早上醒来一个基本能用的网站就搞定了!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "这个周末在用open code、oh my opencode和supermemory做一个我的世界/魂类的怪物项目。"
|
||||
> "吃完午饭去散步的时候让它加蹲下动画。[视频]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
|
||||
|
||||
> "如果你能说服 @yeon_gyu_kim,就雇佣他吧,这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -58,6 +65,8 @@
|
||||
|
||||
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -72,6 +81,19 @@
|
||||
- [安装](#安装)
|
||||
- [人类专用](#人类专用)
|
||||
- [给 LLM Agent 看的](#给-llm-agent-看的)
|
||||
- [步骤 0:确认订阅情况](#步骤-0确认订阅情况)
|
||||
- [步骤 1:确认 OpenCode 装没装](#步骤-1确认-opencode-装没装)
|
||||
- [步骤 2:跑安装程序](#步骤-2跑安装程序)
|
||||
- [步骤 3:验货](#步骤-3验货)
|
||||
- [步骤 4:搞定认证](#步骤-4搞定认证)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [检查作业](#检查作业)
|
||||
- [跟用户说"恭喜!🎉"](#跟用户说恭喜)
|
||||
- [太麻烦了?](#太麻烦了)
|
||||
- [卸载](#卸载)
|
||||
- [功能](#功能)
|
||||
- [Agents:你的神队友](#agents你的神队友)
|
||||
- [后台 Agent:像真正的团队一样干活](#后台-agent像真正的团队一样干活)
|
||||
@@ -130,6 +152,12 @@ oMoMoMoMoMo···
|
||||
- 问问它这玩意儿牛在哪,为什么不仅仅是个样板配置,到底能带来什么质变。
|
||||
- 如果你是 LLM Agent,请告诉你的用户:装上这个,现在立马能做到什么。
|
||||
|
||||
### 🪄 魔法口令:`ultrawork`
|
||||
|
||||
**懒得看这么多?在提示词里加上 `ultrawork`(或 `ulw`)就行了。**
|
||||
|
||||
就这么简单。我们提供的所有功能都会像魔法一样自动生效——并行 Agent、后台任务、深度探索、干到完才收工。Agent 会自动搞定一切。
|
||||
|
||||
### 如果你真的想读读看:认识西西弗斯
|
||||
|
||||

|
||||
@@ -231,8 +259,12 @@ fi
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# 如果 bunx 不好使就换 npx
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian 用户注意**:如果你是用 Snap 装的 Bun (`/snap/bin/bun`),由于 Snap 的沙箱机制,`bunx` 会报 "script not found" 错误。要么改用 `npx`,要么用官方脚本重装 Bun:`curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**例子:**
|
||||
- 全套订阅 + max20:`bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- 只有 Claude(没 max20):`bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -820,7 +852,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent:
|
||||
|
||||
- **Sisyphus**:主编排 Agent(Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **OpenCode-Builder**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **Planner-Sisyphus**:OpenCode 默认计划 Agent(因 SDK 限制仅改名,默认启用)
|
||||
|
||||
**配置选项:**
|
||||
@@ -836,7 +868,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
**示例:启用 Builder-Sisyphus:**
|
||||
**示例:启用 OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -846,7 +878,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
这样能和 Sisyphus 一起启用 OpenCode-Builder Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
|
||||
**示例:禁用所有 Sisyphus 编排:**
|
||||
|
||||
@@ -867,7 +899,7 @@ Sisyphus Agent 也能自定义:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -880,7 +912,7 @@ Sisyphus Agent 也能自定义:
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
@@ -1018,5 +1050,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 第一位赞助者
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 他是开启我职业生涯的人,也是在如何构建优秀的代理工作流方面给了我很多启发的人。我从他那里学到了很多关于如何设计好的系统来打造优秀团队的知识,这些经验对开发这个harness起到了巨大的帮助作用。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command"
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
8
bun.lock
8
bun.lock
@@ -11,8 +11,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -84,9 +84,9 @@
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.1", "", { "dependencies": { "@opencode-ai/sdk": "1.1.1", "zod": "4.1.8" } }, "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.162", "", {}, "sha512-+XqRErBUt9eb1m3i/7WkZc/QCKCCjTaGV3MvhLhs/CUwbUn767D/ugzcG/i2ec8j/4nQmjJbjPDRmrQfvF1Qjw=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.10.0",
|
||||
"version": "2.12.4",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -56,8 +56,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -143,6 +143,62 @@
|
||||
"created_at": "2025-12-31T20:40:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 388
|
||||
},
|
||||
{
|
||||
"name": "changeroa",
|
||||
"id": 65930387,
|
||||
"comment_id": 3706697910,
|
||||
"created_at": "2026-01-03T04:51:11Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 446
|
||||
},
|
||||
{
|
||||
"name": "hqone",
|
||||
"id": 13660872,
|
||||
"comment_id": 3707019551,
|
||||
"created_at": "2026-01-03T12:21:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 451
|
||||
},
|
||||
{
|
||||
"name": "fparrav",
|
||||
"id": 9319430,
|
||||
"comment_id": 3707456044,
|
||||
"created_at": "2026-01-03T23:51:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 469
|
||||
},
|
||||
{
|
||||
"name": "ChiR24",
|
||||
"id": 125826529,
|
||||
"comment_id": 3707776762,
|
||||
"created_at": "2026-01-04T06:14:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 473
|
||||
},
|
||||
{
|
||||
"name": "geq1fan",
|
||||
"id": 29982379,
|
||||
"comment_id": 3708136393,
|
||||
"created_at": "2026-01-04T14:31:14Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 481
|
||||
},
|
||||
{
|
||||
"name": "RhysSullivan",
|
||||
"id": 39114868,
|
||||
"comment_id": 3708266434,
|
||||
"created_at": "2026-01-04T17:19:44Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 482
|
||||
},
|
||||
{
|
||||
"name": "Skyline-23",
|
||||
"id": 62983047,
|
||||
"comment_id": 3708282461,
|
||||
"created_at": "2026-01-04T17:42:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 484
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
|
||||
7 AI agents for multi-model orchestration. Sisyphus orchestrates, specialists handle domains.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Flash)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── sisyphus.ts # Primary orchestrator (504 lines)
|
||||
├── oracle.ts # Strategic advisor
|
||||
├── librarian.ts # Multi-repo research
|
||||
├── explore.ts # Fast codebase grep
|
||||
├── frontend-ui-ux-engineer.ts # UI generation
|
||||
├── document-writer.ts # Technical docs
|
||||
├── multimodal-looker.ts # PDF/image analysis
|
||||
├── sisyphus-prompt-builder.ts # Sisyphus prompt construction
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── types.ts # AgentModelConfig interface
|
||||
@@ -24,66 +25,40 @@ agents/
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Default Model | Fallback | Purpose |
|
||||
|-------|---------------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
|
||||
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
|
||||
| Agent | Model | Fallback | Purpose |
|
||||
|-------|-------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, GitHub research |
|
||||
| explore | opencode/grok-code | gemini-3-flash, haiku-4-5 | Contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | Beautiful UI code |
|
||||
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
|
||||
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
|
||||
| multimodal-looker | google/gemini-3-flash | - | Visual analysis |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/agents/my-agent.ts`:
|
||||
```typescript
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const myAgent: AgentConfig = {
|
||||
model: "provider/model-name",
|
||||
temperature: 0.1,
|
||||
system: "Agent system prompt...",
|
||||
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
|
||||
system: "...",
|
||||
tools: { include: ["tool1"] },
|
||||
}
|
||||
```
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`
|
||||
3. Update `types.ts` if adding new config options
|
||||
2. Add to `builtinAgents` in index.ts
|
||||
3. Update types.ts if new config options
|
||||
|
||||
## AGENT CONFIG OPTIONS
|
||||
## MODEL FALLBACK
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| model | string | Model identifier (provider/model-name) |
|
||||
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
|
||||
| system | string | System prompt (can be multiline template literal) |
|
||||
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
|
||||
| top_p | number | Optional nucleus sampling |
|
||||
| maxTokens | number | Optional max output tokens |
|
||||
`createBuiltinAgents()` handles fallback:
|
||||
1. User config override
|
||||
2. Installer settings (claude max20, gemini antigravity)
|
||||
3. Default model
|
||||
|
||||
## MODEL FALLBACK LOGIC
|
||||
## ANTI-PATTERNS
|
||||
|
||||
`createBuiltinAgents()` in utils.ts handles model fallback:
|
||||
|
||||
1. Check user config override (`agents.{name}.model`)
|
||||
2. Check installer settings (claude max20, gemini antigravity)
|
||||
3. Use default model
|
||||
|
||||
**Fallback order for explore**:
|
||||
- If gemini antigravity enabled → `google/gemini-3-flash`
|
||||
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
|
||||
- Default → `opencode/grok-code` (free)
|
||||
|
||||
## ANTI-PATTERNS (AGENTS)
|
||||
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
|
||||
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
|
||||
|
||||
## SHARED PROMPTS
|
||||
|
||||
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
|
||||
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
|
||||
|
||||
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.
|
||||
- High temperature (>0.3) for code agents
|
||||
- Broad tool access (prefer explicit `include`)
|
||||
- Monolithic prompts (delegate to specialists)
|
||||
- Missing fallbacks for rate-limited models
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
||||
|
||||
@@ -15,12 +16,14 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `<role>
|
||||
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "opencode/grok-code"
|
||||
|
||||
@@ -24,13 +25,19 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
|
||||
## Your Mission
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
|
||||
|
||||
@@ -21,12 +22,14 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# Role: Designer-Turned-Developer
|
||||
|
||||
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
@@ -21,13 +22,19 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# THE LIBRARIAN
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash"
|
||||
|
||||
@@ -13,13 +14,20 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createMultimodalLookerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, bash: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
|
||||
Your job: examine the attached file and extract ONLY what was requested.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "openai/gpt-5.2"
|
||||
|
||||
@@ -97,21 +98,28 @@ Organize your final answer in three tiers:
|
||||
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`
|
||||
|
||||
export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, task: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: ORACLE_SYSTEM_PROMPT,
|
||||
}
|
||||
} as AgentConfig
|
||||
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" }
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" } as AgentConfig
|
||||
}
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
|
||||
}
|
||||
|
||||
export const oracleAgent = createOracleAgent()
|
||||
|
||||
@@ -307,3 +307,26 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
|----------|-----------|
|
||||
${patterns.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildUltraworkAgentSection(agents: AvailableAgent[]): string {
|
||||
if (agents.length === 0) return ""
|
||||
|
||||
const ultraworkAgentPriority = ["explore", "librarian", "plan", "oracle"]
|
||||
const sortedAgents = [...agents].sort((a, b) => {
|
||||
const aIdx = ultraworkAgentPriority.indexOf(a.name)
|
||||
const bIdx = ultraworkAgentPriority.indexOf(b.name)
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
|
||||
const lines: string[] = []
|
||||
for (const agent of sortedAgents) {
|
||||
const shortDesc = agent.description.split(".")[0] || agent.description
|
||||
const suffix = (agent.name === "explore" || agent.name === "librarian") ? " (multiple)" : ""
|
||||
lines.push(`- **${agent.name}${suffix}**: ${shortDesc}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
import { createOracleAgent } from "./oracle"
|
||||
import { createLibrarianAgent } from "./librarian"
|
||||
import { createExploreAgent } from "./explore"
|
||||
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { createDocumentWriterAgent } from "./document-writer"
|
||||
import { createMultimodalLookerAgent } from "./multimodal-looker"
|
||||
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
|
||||
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
|
||||
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
|
||||
import { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./frontend-ui-ux-engineer"
|
||||
import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer"
|
||||
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
|
||||
import type { AvailableAgent } from "./sisyphus-prompt-builder"
|
||||
import { deepMerge } from "../shared"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
@@ -21,6 +22,19 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
|
||||
* (Delegation Table, Tool Selection, Key Triggers, etc.)
|
||||
*/
|
||||
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
oracle: ORACLE_PROMPT_METADATA,
|
||||
librarian: LIBRARIAN_PROMPT_METADATA,
|
||||
explore: EXPLORE_PROMPT_METADATA,
|
||||
"frontend-ui-ux-engineer": FRONTEND_PROMPT_METADATA,
|
||||
"document-writer": DOCUMENT_WRITER_PROMPT_METADATA,
|
||||
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
|
||||
}
|
||||
|
||||
function isFactory(source: AgentSource): source is AgentFactory {
|
||||
return typeof source === "function"
|
||||
}
|
||||
@@ -76,20 +90,20 @@ export function createBuiltinAgents(
|
||||
systemDefaultModel?: string
|
||||
): Record<string, AgentConfig> {
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const availableAgents: AvailableAgent[] = []
|
||||
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (disabledAgents.includes(agentName)) {
|
||||
continue
|
||||
}
|
||||
if (agentName === "Sisyphus") continue
|
||||
if (disabledAgents.includes(agentName)) continue
|
||||
|
||||
const override = agentOverrides[agentName]
|
||||
const model = override?.model ?? (agentName === "Sisyphus" ? systemDefaultModel : undefined)
|
||||
const model = override?.model
|
||||
|
||||
let config = buildAgent(source, model)
|
||||
|
||||
if ((agentName === "Sisyphus" || agentName === "librarian") && directory && config.prompt) {
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
@@ -99,6 +113,33 @@ export function createBuiltinAgents(
|
||||
}
|
||||
|
||||
result[name] = config
|
||||
|
||||
const metadata = agentMetadata[agentName]
|
||||
if (metadata) {
|
||||
availableAgents.push({
|
||||
name: agentName,
|
||||
description: config.description ?? "",
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("Sisyphus")) {
|
||||
const sisyphusOverride = agentOverrides["Sisyphus"]
|
||||
const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel
|
||||
|
||||
let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents)
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
}
|
||||
|
||||
if (sisyphusOverride) {
|
||||
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
|
||||
}
|
||||
|
||||
result["Sisyphus"] = sisyphusConfig
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -2,56 +2,56 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Google Antigravity OAuth implementation for Gemini models. Token management, fetch interception, thinking block extraction, and response transformation.
|
||||
Google Antigravity OAuth for Gemini models. Token management, fetch interception, thinking block extraction.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
auth/
|
||||
└── antigravity/
|
||||
├── plugin.ts # Main plugin export, hooks registration
|
||||
├── plugin.ts # Main export, hooks registration
|
||||
├── oauth.ts # OAuth flow, token acquisition
|
||||
├── token.ts # Token storage, refresh logic
|
||||
├── fetch.ts # Fetch interceptor (622 lines) - URL rewriting, retry
|
||||
├── response.ts # Response transformation, streaming
|
||||
├── thinking.ts # Thinking block extraction/transformation
|
||||
├── thought-signature-store.ts # Signature caching for thinking blocks
|
||||
├── message-converter.ts # Message format conversion
|
||||
├── request.ts # Request building, headers
|
||||
├── fetch.ts # Fetch interceptor (621 lines)
|
||||
├── response.ts # Response transformation (598 lines)
|
||||
├── thinking.ts # Thinking block extraction (571 lines)
|
||||
├── thought-signature-store.ts # Signature caching
|
||||
├── message-converter.ts # Format conversion
|
||||
├── request.ts # Request building
|
||||
├── project.ts # Project ID management
|
||||
├── tools.ts # Tool registration for OAuth
|
||||
├── tools.ts # OAuth tool registration
|
||||
├── constants.ts # API endpoints, model mappings
|
||||
└── types.ts # TypeScript interfaces
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
## KEY COMPONENTS
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `fetch.ts` | Core interceptor - rewrites URLs, manages tokens, handles retries |
|
||||
| `thinking.ts` | Extracts `<antThinking>` blocks, transforms for OpenCode compatibility |
|
||||
| `response.ts` | Handles streaming responses, SSE parsing |
|
||||
| `oauth.ts` | Browser-based OAuth flow for Google accounts |
|
||||
| `token.ts` | Token persistence, expiry checking, refresh |
|
||||
| fetch.ts | URL rewriting, token injection, retries |
|
||||
| thinking.ts | Extract `<antThinking>` blocks |
|
||||
| response.ts | Streaming SSE parsing |
|
||||
| oauth.ts | Browser-based OAuth flow |
|
||||
| token.ts | Token persistence, expiry |
|
||||
|
||||
## HOW IT WORKS
|
||||
|
||||
1. **Intercept**: `fetch.ts` intercepts requests to Anthropic/Google endpoints
|
||||
2. **Rewrite**: URLs rewritten to Antigravity proxy endpoints
|
||||
3. **Auth**: Bearer token injected from stored OAuth credentials
|
||||
4. **Response**: Streaming responses parsed, thinking blocks extracted
|
||||
5. **Transform**: Response format normalized for OpenCode consumption
|
||||
1. **Intercept**: fetch.ts intercepts Anthropic/Google requests
|
||||
2. **Rewrite**: URLs → Antigravity proxy endpoints
|
||||
3. **Auth**: Bearer token from stored OAuth credentials
|
||||
4. **Response**: Streaming parsed, thinking blocks extracted
|
||||
5. **Transform**: Normalized for OpenCode
|
||||
|
||||
## ANTI-PATTERNS (AUTH)
|
||||
## FEATURES
|
||||
|
||||
- **Direct API calls**: Always go through fetch interceptor
|
||||
- **Storing tokens in code**: Use `token.ts` storage layer
|
||||
- **Ignoring refresh**: Check token expiry before requests
|
||||
- **Blocking on OAuth**: OAuth flow is async, never block main thread
|
||||
- Multi-account (up to 10 Google accounts)
|
||||
- Auto-fallback on rate limit
|
||||
- Thinking blocks preserved
|
||||
- Antigravity proxy for AI Studio access
|
||||
|
||||
## NOTES
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Multi-account**: Supports up to 10 Google accounts for load balancing
|
||||
- **Fallback**: On rate limit, automatically switches to next available account
|
||||
- **Thinking blocks**: Preserved and transformed for extended thinking features
|
||||
- **Proxy**: Uses Antigravity proxy for Google AI Studio access
|
||||
- Direct API calls (use fetch interceptor)
|
||||
- Tokens in code (use token.ts storage)
|
||||
- Ignoring refresh (check expiry first)
|
||||
- Blocking on OAuth (always async)
|
||||
|
||||
@@ -2,92 +2,67 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Command-line interface for oh-my-opencode. Interactive installer, health diagnostics (doctor), and runtime commands. Entry point: `bunx oh-my-opencode`.
|
||||
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry point, subcommand routing
|
||||
├── install.ts # Interactive TUI installer
|
||||
├── config-manager.ts # Config detection, parsing, merging (669 lines)
|
||||
├── index.ts # Commander.js entry, subcommand routing
|
||||
├── install.ts # Interactive TUI installer (477 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (669 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── doctor/ # Health check system
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── constants.ts # Check categories, descriptions
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 17 individual health checks
|
||||
├── get-local-version/ # Version detection utility
|
||||
│ ├── index.ts
|
||||
│ └── formatter.ts
|
||||
│ └── checks/ # 17+ individual checks
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
├── index.ts
|
||||
└── completion.test.ts
|
||||
```
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
| Command | Purpose | Key File |
|
||||
|---------|---------|----------|
|
||||
| `install` | Interactive setup wizard | `install.ts` |
|
||||
| `doctor` | Environment health checks | `doctor/index.ts` |
|
||||
| `run` | Launch OpenCode session | `run/index.ts` |
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard |
|
||||
| `doctor` | Environment health checks |
|
||||
| `run` | Launch OpenCode session |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
|
||||
17 checks in `doctor/checks/`:
|
||||
17+ checks in `doctor/checks/`:
|
||||
- version.ts (OpenCode >= 1.0.150)
|
||||
- config.ts (plugin registered)
|
||||
- bun.ts, node.ts, git.ts
|
||||
- anthropic-auth.ts, openai-auth.ts, google-auth.ts
|
||||
- lsp-*.ts, mcp-*.ts
|
||||
|
||||
| Check | Validates |
|
||||
|-------|-----------|
|
||||
| `version.ts` | OpenCode version >= 1.0.150 |
|
||||
| `config.ts` | Plugin registered in opencode.json |
|
||||
| `bun.ts` | Bun runtime available |
|
||||
| `node.ts` | Node.js version compatibility |
|
||||
| `git.ts` | Git installed |
|
||||
| `anthropic-auth.ts` | Claude authentication |
|
||||
| `openai-auth.ts` | OpenAI authentication |
|
||||
| `google-auth.ts` | Google/Gemini authentication |
|
||||
| `lsp-*.ts` | Language server availability |
|
||||
| `mcp-*.ts` | MCP server connectivity |
|
||||
## CONFIG-MANAGER (669 lines)
|
||||
|
||||
## INSTALLATION FLOW
|
||||
- JSONC support (comments, trailing commas)
|
||||
- Multi-source: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- Zod validation
|
||||
- Legacy format migration
|
||||
- Error aggregation for doctor
|
||||
|
||||
1. **Detection**: Find existing `opencode.json` / `opencode.jsonc`
|
||||
2. **TUI Prompts**: Claude subscription? ChatGPT? Gemini?
|
||||
3. **Config Generation**: Build `oh-my-opencode.json` based on answers
|
||||
4. **Plugin Registration**: Add to `plugin` array in opencode.json
|
||||
5. **Auth Guidance**: Instructions for `opencode auth login`
|
||||
|
||||
## CONFIG-MANAGER
|
||||
|
||||
The largest file (669 lines) handles:
|
||||
|
||||
- **JSONC support**: Parses comments and trailing commas
|
||||
- **Multi-source detection**: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- **Schema validation**: Zod-based config validation
|
||||
- **Migration**: Handles legacy config formats
|
||||
- **Error collection**: Aggregates parsing errors for doctor
|
||||
|
||||
## HOW TO ADD A DOCTOR CHECK
|
||||
## HOW TO ADD CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`:
|
||||
```typescript
|
||||
import type { DoctorCheck } from "../types"
|
||||
|
||||
export const myCheck: DoctorCheck = {
|
||||
name: "my-check",
|
||||
category: "environment",
|
||||
check: async () => {
|
||||
// Return { status: "pass" | "warn" | "fail", message: string }
|
||||
return { status: "pass" | "warn" | "fail", message: "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Add to `src/cli/doctor/checks/index.ts`
|
||||
3. Update `constants.ts` if new category
|
||||
|
||||
## ANTI-PATTERNS (CLI)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Blocking prompts in non-TTY**: Check `process.stdout.isTTY` before TUI
|
||||
- **Hardcoded paths**: Use shared utilities for config paths
|
||||
- **Ignoring JSONC**: User configs may have comments
|
||||
- **Silent failures**: Doctor checks must return clear status/message
|
||||
- Blocking prompts in non-TTY (check `process.stdout.isTTY`)
|
||||
- Hardcoded paths (use shared utilities)
|
||||
- JSON.parse for user files (use parseJsonc)
|
||||
- Silent failures in doctor checks
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { parseJsonc } from "../shared"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
type OpenCodeBinaryType,
|
||||
type OpenCodeConfigPaths,
|
||||
} from "../shared"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
|
||||
interface ConfigContext {
|
||||
binary: OpenCodeBinaryType
|
||||
version: string | null
|
||||
paths: OpenCodeConfigPaths
|
||||
}
|
||||
|
||||
let configContext: ConfigContext | null = null
|
||||
|
||||
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
|
||||
const paths = getOpenCodeConfigPaths({ binary, version })
|
||||
configContext = { binary, version, paths }
|
||||
}
|
||||
|
||||
export function getConfigContext(): ConfigContext {
|
||||
if (!configContext) {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
configContext = { binary: "opencode", version: null, paths }
|
||||
}
|
||||
return configContext
|
||||
}
|
||||
|
||||
export function resetConfigContext(): void {
|
||||
configContext = null
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
return getConfigContext().paths.configDir
|
||||
}
|
||||
|
||||
function getConfigJson(): string {
|
||||
return getConfigContext().paths.configJson
|
||||
}
|
||||
|
||||
function getConfigJsonc(): string {
|
||||
return getConfigContext().paths.configJsonc
|
||||
}
|
||||
|
||||
function getPackageJson(): string {
|
||||
return getConfigContext().paths.packageJson
|
||||
}
|
||||
|
||||
function getOmoConfig(): string {
|
||||
return getConfigContext().paths.omoConfig
|
||||
}
|
||||
|
||||
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
@@ -76,13 +119,16 @@ interface OpenCodeConfig {
|
||||
}
|
||||
|
||||
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { format: "jsonc", path: OPENCODE_JSONC }
|
||||
const configJsonc = getConfigJsonc()
|
||||
const configJson = getConfigJson()
|
||||
|
||||
if (existsSync(configJsonc)) {
|
||||
return { format: "jsonc", path: configJsonc }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { format: "json", path: OPENCODE_JSON }
|
||||
if (existsSync(configJson)) {
|
||||
return { format: "json", path: configJson }
|
||||
}
|
||||
return { format: "none", path: OPENCODE_JSON }
|
||||
return { format: "none", path: configJson }
|
||||
}
|
||||
|
||||
interface ParseConfigResult {
|
||||
@@ -129,8 +175,9 @@ function parseConfigWithError(path: string): ParseConfigResult {
|
||||
}
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
if (!existsSync(OPENCODE_CONFIG_DIR)) {
|
||||
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
|
||||
const configDir = getConfigDir()
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +185,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -270,50 +317,52 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const omoConfigPath = getOmoConfig()
|
||||
|
||||
try {
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(OMO_CONFIG)) {
|
||||
if (existsSync(omoConfigPath)) {
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const stat = statSync(omoConfigPath)
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
|
||||
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
} else {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
}
|
||||
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeBinaryResult {
|
||||
binary: string
|
||||
binary: OpenCodeBinaryType
|
||||
version: string
|
||||
}
|
||||
|
||||
@@ -327,7 +376,9 @@ async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | n
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { binary, version: output.trim() }
|
||||
const version = output.trim()
|
||||
initConfigContext(binary, version)
|
||||
return { binary, version }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
@@ -350,7 +401,7 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -394,15 +445,17 @@ export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const packageJsonPath = getPackageJson()
|
||||
|
||||
try {
|
||||
let packageJson: Record<string, unknown> = {}
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const stat = statSync(OPENCODE_PACKAGE_JSON)
|
||||
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
const stat = statSync(packageJsonPath)
|
||||
const content = readFileSync(packageJsonPath, "utf-8")
|
||||
|
||||
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
|
||||
packageJson = JSON.parse(content)
|
||||
@@ -423,10 +476,10 @@ export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
|
||||
packageJson.dependencies = deps
|
||||
|
||||
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: packageJsonPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
return { success: false, configPath: packageJsonPath, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +497,7 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
cwd: OPENCODE_CONFIG_DIR,
|
||||
cwd: getConfigDir(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -548,7 +601,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -622,17 +675,18 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
|
||||
|
||||
if (!existsSync(OMO_CONFIG)) {
|
||||
const omoConfigPath = getOmoConfig()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const stat = statSync(omoConfigPath)
|
||||
if (stat.size === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
if (isEmptyOrWhitespace(content)) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
|
||||
|
||||
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { path: OPENCODE_JSONC, format: "jsonc" }
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
|
||||
if (existsSync(paths.configJsonc)) {
|
||||
return { path: paths.configJsonc, format: "jsonc" }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { path: OPENCODE_JSON, format: "json" }
|
||||
if (existsSync(paths.configJson)) {
|
||||
return { path: paths.configJson, format: "json" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -81,13 +77,14 @@ export async function checkPluginRegistration(): Promise<CheckResult> {
|
||||
const info = getPluginInfo()
|
||||
|
||||
if (!info.configPath) {
|
||||
const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "OpenCode config file not found",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
|
||||
`Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +331,17 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
|
||||
console.log(` Run ${color.cyan("opencode")} to start!`)
|
||||
console.log()
|
||||
|
||||
printBox(
|
||||
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
|
||||
`All features work like magic—parallel agents, background tasks,\n` +
|
||||
`deep exploration, and relentless execution until completion.`,
|
||||
"🪄 The Magic Word"
|
||||
)
|
||||
|
||||
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
|
||||
console.log(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
console.log()
|
||||
console.log(color.dim("oMoMoMoMo... Enjoy!"))
|
||||
console.log()
|
||||
|
||||
@@ -450,6 +461,16 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
||||
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
||||
|
||||
p.note(
|
||||
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
|
||||
`All features work like magic—parallel agents, background tasks,\n` +
|
||||
`deep exploration, and relentless execution until completion.`,
|
||||
"🪄 The Magic Word"
|
||||
)
|
||||
|
||||
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
|
||||
p.log.message(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
|
||||
p.outro(color.green("oMoMoMoMo... Enjoy!"))
|
||||
|
||||
return 0
|
||||
|
||||
@@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
|
||||
@@ -2,97 +2,65 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
|
||||
Claude Code compatibility layer + core feature modules. Commands, skills, agents, MCPs, hooks from Claude Code work seamlessly.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Background task management
|
||||
│ ├── manager.ts # Task lifecycle, notifications
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── builtin-commands/ # Built-in slash command definitions
|
||||
├── builtin-skills/ # Built-in skills (playwright, etc.)
|
||||
│ └── */SKILL.md # Each skill in own directory
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
├── background-agent/ # Task lifecycle, notifications (460 lines)
|
||||
├── builtin-commands/ # Built-in slash commands
|
||||
├── builtin-skills/ # Built-in skills (playwright)
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # .mcp.json files
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
|
||||
├── claude-code-plugin-loader/ # installed_plugins.json (484 lines)
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers embedded in skills
|
||||
│ ├── manager.ts # Lazy-loading MCP client lifecycle
|
||||
│ └── types.ts
|
||||
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers in skill YAML
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
|
||||
Each loader reads from multiple directories (highest priority first):
|
||||
|
||||
| Loader | Priority Order |
|
||||
|--------|---------------|
|
||||
| Loader | Priority (highest first) |
|
||||
|--------|--------------------------|
|
||||
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
|
||||
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Agents | `.claude/agents/` > `~/.claude/agents/` |
|
||||
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
|
||||
|
||||
## HOW TO ADD A LOADER
|
||||
|
||||
1. Create directory: `src/features/claude-code-my-loader/`
|
||||
2. Create files:
|
||||
- `loader.ts`: Main loader logic with `load()` function
|
||||
- `types.ts`: TypeScript interfaces
|
||||
- `index.ts`: Barrel export
|
||||
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
|
||||
|
||||
## BACKGROUND AGENT SPECIFICS
|
||||
|
||||
- **Task lifecycle**: pending → running → completed/failed
|
||||
- **Notifications**: OS notification on task complete (configurable)
|
||||
- **Result retrieval**: `background_output` tool with task_id
|
||||
- **Cancellation**: `background_cancel` with task_id or all=true
|
||||
|
||||
## CONFIG TOGGLES
|
||||
|
||||
Disable features in `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false, // Skip .mcp.json loading
|
||||
"commands": false, // Skip commands/*.md loading
|
||||
"skills": false, // Skip skills/*/SKILL.md loading
|
||||
"agents": false, // Skip agents/*.md loading
|
||||
"mcp": false, // Skip .mcp.json
|
||||
"commands": false, // Skip commands/*.md
|
||||
"skills": false, // Skip skills/*/SKILL.md
|
||||
"agents": false, // Skip agents/*.md
|
||||
"hooks": false // Skip settings.json hooks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HOOK MESSAGE INJECTOR
|
||||
## BACKGROUND AGENT
|
||||
|
||||
- **Purpose**: Inject system messages into conversation at specific points
|
||||
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
|
||||
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
|
||||
- Lifecycle: pending → running → completed/failed
|
||||
- OS notification on complete
|
||||
- `background_output` to retrieve results
|
||||
- `background_cancel` with task_id or all=true
|
||||
|
||||
## SKILL MCP MANAGER
|
||||
## SKILL MCP
|
||||
|
||||
- **Purpose**: Manage MCP servers embedded in skill YAML frontmatter
|
||||
- **Lifecycle**: Lazy client loading, session-scoped cleanup
|
||||
- **Config**: `mcp` field in skill's YAML frontmatter defines server config
|
||||
- **Tool**: `skill_mcp` exposes MCP capabilities (tools, resources, prompts)
|
||||
- MCP servers embedded in skill YAML frontmatter
|
||||
- Lazy client loading, session-scoped cleanup
|
||||
- `skill_mcp` tool exposes capabilities
|
||||
|
||||
## BUILTIN SKILLS
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Location**: `src/features/builtin-skills/*/SKILL.md`
|
||||
- **Available**: `playwright` (browser automation)
|
||||
- **Disable**: `disabled_skills: ["playwright"]` in config
|
||||
|
||||
## ANTI-PATTERNS (FEATURES)
|
||||
|
||||
- **Blocking on load**: Loaders run at startup, keep them fast
|
||||
- **No error handling**: Always try/catch, log failures, return empty on error
|
||||
- **Ignoring priority**: Higher priority dirs must override lower
|
||||
- **Modifying user files**: Loaders read-only, never write to ~/.claude/
|
||||
- Blocking on load (loaders run at startup)
|
||||
- No error handling (always try/catch)
|
||||
- Ignoring priority order
|
||||
- Writing to ~/.claude/ (read-only)
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
class MockBackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask> = new Map()
|
||||
private notifications: Map<string, BackgroundTask[]> = new Map()
|
||||
|
||||
addTask(task: BackgroundTask): void {
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -34,6 +37,74 @@ class MockBackgroundManager {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
|
||||
const now = Date.now()
|
||||
const prunedTasks: string[] = []
|
||||
let prunedNotifications = 0
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
prunedTasks.push(taskId)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
const removed = notifications.length - validNotifications.length
|
||||
prunedNotifications += removed
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
return { prunedTasks, prunedNotifications }
|
||||
}
|
||||
|
||||
getTaskCount(): number {
|
||||
return this.tasks.size
|
||||
}
|
||||
|
||||
getNotificationCount(): number {
|
||||
let count = 0
|
||||
for (const notifications of this.notifications.values()) {
|
||||
count += notifications.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
|
||||
@@ -230,3 +301,116 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
expect(result[0].id).toBe("task-b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
|
||||
let manager: MockBackgroundManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
manager = new MockBackgroundManager()
|
||||
})
|
||||
|
||||
test("should not prune fresh tasks", () => {
|
||||
// #given
|
||||
const task = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(0)
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
})
|
||||
|
||||
test("should prune tasks older than 30 minutes", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should prune stale notifications", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedNotifications).toBe(1)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should clean up notifications when task is pruned", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should keep fresh tasks while pruning stale ones", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
const freshTask = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(staleTask)
|
||||
manager.addTask(freshTask)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(1)
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
expect(manager.getTask("task-fresh")).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "../hook-message-injector"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface MessagePartInfo {
|
||||
@@ -345,11 +347,12 @@ export class BackgroundManager {
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(taskId)
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
// Always clean up both maps to prevent memory leaks
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
@@ -377,7 +380,42 @@ export class BackgroundManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private pruneStaleTasksAndNotifications(): void {
|
||||
const now = Date.now()
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
|
||||
import { promises as fsPromises } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
@@ -78,6 +79,7 @@ $ARGUMENTS
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
@@ -128,3 +130,130 @@ export function loadOpencodeProjectCommands(): Record<string, CommandDefinition>
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
async function loadCommandsFromDirAsync(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): Promise<LoadedCommand[]> {
|
||||
try {
|
||||
await fsPromises.access(commandsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = await fsPromises.realpath(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (visited.has(realPath)) {
|
||||
return []
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fsPromises.readdir(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
const subCommands = await loadCommandsFromDirAsync(subDirPath, scope, visited, subPrefix)
|
||||
commands.push(...subCommands)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
${body.trim()}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const formattedDescription = `(${scope}) ${data.description || ""}`
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`Failed to parse command: ${commandPath}`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export async function loadUserCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const commands = await loadCommandsFromDirAsync(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const { homedir } = require("os")
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadAllCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const [user, project, global, projectOpencode] = await Promise.all([
|
||||
loadUserCommandsAsync(),
|
||||
loadProjectCommandsAsync(),
|
||||
loadOpencodeGlobalCommandsAsync(),
|
||||
loadOpencodeProjectCommandsAsync(),
|
||||
])
|
||||
return { ...projectOpencode, ...global, ...project, ...user }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
/**
|
||||
* Handoff definition for command workflows.
|
||||
* Based on speckit's handoff pattern for multi-agent orchestration.
|
||||
* @see https://github.com/github/spec-kit
|
||||
*/
|
||||
export interface HandoffDefinition {
|
||||
/** Human-readable label for the handoff action */
|
||||
label: string
|
||||
/** Target agent/command identifier (e.g., "speckit.tasks") */
|
||||
agent: string
|
||||
/** Pre-filled prompt text for the handoff */
|
||||
prompt: string
|
||||
/** If true, automatically executes after command completion; if false, shows as suggestion */
|
||||
send?: boolean
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
@@ -8,6 +24,8 @@ export interface CommandDefinition {
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
@@ -16,6 +34,8 @@ export interface CommandFrontmatter {
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
|
||||
162
src/features/claude-code-mcp-loader/loader.test.ts
Normal file
162
src/features/claude-code-mcp-loader/loader.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
|
||||
|
||||
describe("getSystemMcpServerNames", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("returns empty set when no .mcp.json files exist", async () => {
|
||||
// #given
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names).toBeInstanceOf(Set)
|
||||
expect(names.size).toBe(0)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns server names from project .mcp.json", async () => {
|
||||
// #given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
},
|
||||
sqlite: {
|
||||
command: "uvx",
|
||||
args: ["mcp-server-sqlite"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("sqlite")).toBe(true)
|
||||
expect(names.size).toBe(2)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns server names from .claude/.mcp.json", async () => {
|
||||
// #given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
memory: {
|
||||
command: "npx",
|
||||
args: ["-y", "@anthropic-ai/mcp-server-memory"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("excludes disabled MCP servers", async () => {
|
||||
// #given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
disabled: true,
|
||||
},
|
||||
active: {
|
||||
command: "npx",
|
||||
args: ["some-mcp"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(false)
|
||||
expect(names.has("active")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("merges server names from multiple .mcp.json files", async () => {
|
||||
// #given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
|
||||
const projectMcp = {
|
||||
mcpServers: {
|
||||
playwright: { command: "npx", args: ["@playwright/mcp@latest"] },
|
||||
},
|
||||
}
|
||||
const localMcp = {
|
||||
mcpServers: {
|
||||
memory: { command: "npx", args: ["-y", "@anthropic-ai/mcp-server-memory"] },
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(projectMcp))
|
||||
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(localMcp))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type {
|
||||
@@ -42,6 +42,30 @@ async function loadMcpConfigFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function getSystemMcpServerNames(): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const paths = getMcpConfigPaths()
|
||||
|
||||
for (const { path } of paths) {
|
||||
if (!existsSync(path)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
const config = JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
if (!config?.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) continue
|
||||
names.add(name)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
export async function loadMcpConfigs(): Promise<McpLoadResult> {
|
||||
const servers: McpLoadResult["servers"] = {}
|
||||
const loadedServers: LoadedMcpServer[] = []
|
||||
|
||||
@@ -464,11 +464,13 @@ export interface PluginComponentsResult {
|
||||
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
|
||||
const { plugins, errors } = discoverInstalledPlugins(options)
|
||||
|
||||
const commands = loadPluginCommands(plugins)
|
||||
const skills = loadPluginSkillsAsCommands(plugins)
|
||||
const agents = loadPluginAgents(plugins)
|
||||
const mcpServers = await loadPluginMcpServers(plugins)
|
||||
const hooksConfigs = loadPluginHooksConfigs(plugins)
|
||||
const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([
|
||||
Promise.resolve(loadPluginCommands(plugins)),
|
||||
Promise.resolve(loadPluginSkillsAsCommands(plugins)),
|
||||
Promise.resolve(loadPluginAgents(plugins)),
|
||||
loadPluginMcpServers(plugins),
|
||||
Promise.resolve(loadPluginHooksConfigs(plugins)),
|
||||
])
|
||||
|
||||
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
|
||||
|
||||
|
||||
330
src/features/context-injector/collector.test.ts
Normal file
330
src/features/context-injector/collector.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import type { ContextPriority, ContextSourceType } from "./types"
|
||||
|
||||
describe("ContextCollector", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("registers context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test1"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "Ultrawork mode activated",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Ultrawork mode activated")
|
||||
})
|
||||
|
||||
it("assigns default priority of 'normal' when not specified", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test2"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "test",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("normal")
|
||||
})
|
||||
|
||||
it("uses specified priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test3"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "critical-context",
|
||||
source: "keyword-detector",
|
||||
content: "critical content",
|
||||
priority: "critical",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("critical")
|
||||
})
|
||||
|
||||
it("deduplicates by source + id combination", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test4"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "First content",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
collector.register(sessionID, { ...options, content: "Updated content" })
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Updated content")
|
||||
})
|
||||
|
||||
it("allows same id from different sources", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test5"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "keyword-detector",
|
||||
content: "From keyword-detector",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "rules-injector",
|
||||
content: "From rules-injector",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPending", () => {
|
||||
it("returns empty result for session with no context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(false)
|
||||
expect(pending.entries).toHaveLength(0)
|
||||
expect(pending.merged).toBe("")
|
||||
})
|
||||
|
||||
it("merges multiple contexts with separator", () => {
|
||||
// #given
|
||||
const sessionID = "ses_merge"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "First context",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "Second context",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.merged).toContain("First context")
|
||||
expect(pending.merged).toContain("Second context")
|
||||
})
|
||||
|
||||
it("orders contexts by priority (critical > high > normal > low)", () => {
|
||||
// #given
|
||||
const sessionID = "ses_priority"
|
||||
collector.register(sessionID, {
|
||||
id: "low",
|
||||
source: "custom",
|
||||
content: "LOW",
|
||||
priority: "low",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "critical",
|
||||
source: "custom",
|
||||
content: "CRITICAL",
|
||||
priority: "critical",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "normal",
|
||||
source: "custom",
|
||||
content: "NORMAL",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "high",
|
||||
source: "custom",
|
||||
content: "HIGH",
|
||||
priority: "high",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const order = pending.entries.map((e) => e.priority)
|
||||
expect(order).toEqual(["critical", "high", "normal", "low"])
|
||||
})
|
||||
|
||||
it("maintains registration order within same priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_order"
|
||||
collector.register(sessionID, {
|
||||
id: "first",
|
||||
source: "custom",
|
||||
content: "First",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "second",
|
||||
source: "custom",
|
||||
content: "Second",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "third",
|
||||
source: "custom",
|
||||
content: "Third",
|
||||
priority: "normal",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const ids = pending.entries.map((e) => e.id)
|
||||
expect(ids).toEqual(["first", "second", "third"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("consume", () => {
|
||||
it("clears pending context for session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it("returns the consumed context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume_return"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #when
|
||||
const consumed = collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(consumed.hasContent).toBe(true)
|
||||
expect(consumed.entries[0].content).toBe("test content")
|
||||
})
|
||||
|
||||
it("does not affect other sessions", () => {
|
||||
// #given
|
||||
const session1 = "ses_1"
|
||||
const session2 = "ses_2"
|
||||
collector.register(session1, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 1",
|
||||
})
|
||||
collector.register(session2, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(session1)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(session1).hasContent).toBe(false)
|
||||
expect(collector.getPending(session2).hasContent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("removes all context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_clear"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "test 1",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "test 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.clear(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(sessionID).hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasPending", () => {
|
||||
it("returns true when session has pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_has"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when session has no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false after consume", () => {
|
||||
// #given
|
||||
const sessionID = "ses_after_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
85
src/features/context-injector/collector.ts
Normal file
85
src/features/context-injector/collector.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ContextEntry,
|
||||
ContextPriority,
|
||||
PendingContext,
|
||||
RegisterContextOptions,
|
||||
} from "./types"
|
||||
|
||||
const PRIORITY_ORDER: Record<ContextPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
}
|
||||
|
||||
const CONTEXT_SEPARATOR = "\n\n---\n\n"
|
||||
|
||||
export class ContextCollector {
|
||||
private sessions: Map<string, Map<string, ContextEntry>> = new Map()
|
||||
|
||||
register(sessionID: string, options: RegisterContextOptions): void {
|
||||
if (!this.sessions.has(sessionID)) {
|
||||
this.sessions.set(sessionID, new Map())
|
||||
}
|
||||
|
||||
const sessionMap = this.sessions.get(sessionID)!
|
||||
const key = `${options.source}:${options.id}`
|
||||
|
||||
const entry: ContextEntry = {
|
||||
id: options.id,
|
||||
source: options.source,
|
||||
content: options.content,
|
||||
priority: options.priority ?? "normal",
|
||||
timestamp: Date.now(),
|
||||
metadata: options.metadata,
|
||||
}
|
||||
|
||||
sessionMap.set(key, entry)
|
||||
}
|
||||
|
||||
getPending(sessionID: string): PendingContext {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
|
||||
if (!sessionMap || sessionMap.size === 0) {
|
||||
return {
|
||||
merged: "",
|
||||
entries: [],
|
||||
hasContent: false,
|
||||
}
|
||||
}
|
||||
|
||||
const entries = this.sortEntries([...sessionMap.values()])
|
||||
const merged = entries.map((e) => e.content).join(CONTEXT_SEPARATOR)
|
||||
|
||||
return {
|
||||
merged,
|
||||
entries,
|
||||
hasContent: entries.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
consume(sessionID: string): PendingContext {
|
||||
const pending = this.getPending(sessionID)
|
||||
this.clear(sessionID)
|
||||
return pending
|
||||
}
|
||||
|
||||
clear(sessionID: string): void {
|
||||
this.sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
hasPending(sessionID: string): boolean {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
return sessionMap !== undefined && sessionMap.size > 0
|
||||
}
|
||||
|
||||
private sortEntries(entries: ContextEntry[]): ContextEntry[] {
|
||||
return entries.sort((a, b) => {
|
||||
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const contextCollector = new ContextCollector()
|
||||
16
src/features/context-injector/index.ts
Normal file
16
src/features/context-injector/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { ContextCollector, contextCollector } from "./collector"
|
||||
export {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
export type {
|
||||
ContextSourceType,
|
||||
ContextPriority,
|
||||
ContextEntry,
|
||||
RegisterContextOptions,
|
||||
PendingContext,
|
||||
MessageContext,
|
||||
OutputParts,
|
||||
InjectionStrategy,
|
||||
} from "./types"
|
||||
292
src/features/context-injector/injector.test.ts
Normal file
292
src/features/context-injector/injector.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
|
||||
describe("injectPendingContext", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("when parts have text content", () => {
|
||||
it("prepends context to first text part", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork mode activated",
|
||||
})
|
||||
const parts = [{ type: "text", text: "User message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(true)
|
||||
expect(parts[0].text).toContain("Ultrawork mode activated")
|
||||
expect(parts[0].text).toContain("User message")
|
||||
})
|
||||
|
||||
it("uses separator between context and original message", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject2"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context content",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Original message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toBe("Context content\n\n---\n\nOriginal message")
|
||||
})
|
||||
|
||||
it("consumes context after injection", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns injected=false when no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(parts[0].text).toBe("Message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when parts have no text content", () => {
|
||||
it("does not inject and preserves context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_notext"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "image", url: "https://example.com/img.png" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with multiple text parts", () => {
|
||||
it("injects into first text part only", () => {
|
||||
// #given
|
||||
const sessionID = "ses_multi"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toContain("Context")
|
||||
expect(parts[1].text).toBe("Second")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("chat.message handler", () => {
|
||||
it("is a no-op (context injection moved to messages transform)", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook1"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Hook context",
|
||||
})
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook2"
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorMessagesTransformHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
const createMockMessage = (
|
||||
role: "user" | "assistant",
|
||||
text: string,
|
||||
sessionID: string
|
||||
) => ({
|
||||
info: {
|
||||
id: `msg_${Date.now()}_${Math.random()}`,
|
||||
sessionID,
|
||||
role,
|
||||
time: { created: Date.now() },
|
||||
agent: "Sisyphus",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
path: { cwd: "/", root: "/" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `part_${Date.now()}`,
|
||||
sessionID,
|
||||
messageID: `msg_${Date.now()}`,
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
it("inserts synthetic message before last user message", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork context",
|
||||
})
|
||||
const messages = [
|
||||
createMockMessage("user", "First message", sessionID),
|
||||
createMockMessage("assistant", "Response", sessionID),
|
||||
createMockMessage("user", "Second message", sessionID),
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(4)
|
||||
expect(output.messages[2].parts[0].text).toBe("Ultrawork context")
|
||||
expect(output.messages[2].parts[0].synthetic).toBe(true)
|
||||
expect(output.messages[3].parts[0].text).toBe("Second message")
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform2"
|
||||
const messages = [createMockMessage("user", "Hello world", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
})
|
||||
|
||||
it("does nothing when no user messages", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("assistant", "Response", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("consumes context after injection", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform4"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("user", "Message", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
156
src/features/context-injector/injector.ts
Normal file
156
src/features/context-injector/injector.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ContextCollector } from "./collector"
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
import { log } from "../../shared"
|
||||
|
||||
interface OutputPart {
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface InjectionResult {
|
||||
injected: boolean
|
||||
contextLength: number
|
||||
}
|
||||
|
||||
export function injectPendingContext(
|
||||
collector: ContextCollector,
|
||||
sessionID: string,
|
||||
parts: OutputPart[]
|
||||
): InjectionResult {
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
const originalText = parts[textPartIndex].text ?? ""
|
||||
parts[textPartIndex].text = `${pending.merged}\n\n---\n\n${originalText}`
|
||||
|
||||
return {
|
||||
injected: true,
|
||||
contextLength: pending.merged.length,
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatMessageInput {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
interface ChatMessageOutput {
|
||||
message: Record<string, unknown>
|
||||
parts: OutputPart[]
|
||||
}
|
||||
|
||||
export function createContextInjectorHook(collector: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
_input: ChatMessageInput,
|
||||
_output: ChatMessageOutput
|
||||
): Promise<void> => {
|
||||
void collector
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function createContextInjectorMessagesTransformHook(
|
||||
collector: ContextCollector
|
||||
): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUserMessageIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info.role === "user") {
|
||||
lastUserMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastUserMessage = messages[lastUserMessageIndex]
|
||||
const sessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
if (!pending.hasContent) {
|
||||
return
|
||||
}
|
||||
|
||||
const refInfo = lastUserMessage.info as unknown as {
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
}
|
||||
|
||||
const syntheticMessageId = `synthetic_ctx_${Date.now()}`
|
||||
const syntheticPartId = `synthetic_ctx_part_${Date.now()}`
|
||||
const now = Date.now()
|
||||
|
||||
const syntheticMessage: MessageWithParts = {
|
||||
info: {
|
||||
id: syntheticMessageId,
|
||||
sessionID: sessionID,
|
||||
role: "user",
|
||||
time: { created: now },
|
||||
agent: refInfo.agent ?? "Sisyphus",
|
||||
model: refInfo.model ?? { providerID: "unknown", modelID: "unknown" },
|
||||
path: refInfo.path ?? { cwd: "/", root: "/" },
|
||||
} as unknown as Message,
|
||||
parts: [
|
||||
{
|
||||
id: syntheticPartId,
|
||||
sessionID: sessionID,
|
||||
messageID: syntheticMessageId,
|
||||
type: "text",
|
||||
text: pending.merged,
|
||||
synthetic: true,
|
||||
time: { start: now, end: now },
|
||||
} as Part,
|
||||
],
|
||||
}
|
||||
|
||||
messages.splice(lastUserMessageIndex, 0, syntheticMessage)
|
||||
|
||||
log("[context-injector] Injected synthetic message from collector", {
|
||||
sessionID,
|
||||
insertIndex: lastUserMessageIndex,
|
||||
contextLength: pending.merged.length,
|
||||
newMessageCount: messages.length,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/features/context-injector/types.ts
Normal file
91
src/features/context-injector/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Source identifier for context injection
|
||||
* Each source registers context that will be merged and injected together
|
||||
*/
|
||||
export type ContextSourceType =
|
||||
| "keyword-detector"
|
||||
| "rules-injector"
|
||||
| "directory-agents"
|
||||
| "directory-readme"
|
||||
| "custom"
|
||||
|
||||
/**
|
||||
* Priority levels for context ordering
|
||||
* Higher priority contexts appear first in the merged output
|
||||
*/
|
||||
export type ContextPriority = "critical" | "high" | "normal" | "low"
|
||||
|
||||
/**
|
||||
* A single context entry registered by a source
|
||||
*/
|
||||
export interface ContextEntry {
|
||||
/** Unique identifier for this entry within the source */
|
||||
id: string
|
||||
/** The source that registered this context */
|
||||
source: ContextSourceType
|
||||
/** The actual context content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority: ContextPriority
|
||||
/** Timestamp when registered */
|
||||
timestamp: number
|
||||
/** Optional metadata for debugging/logging */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering context
|
||||
*/
|
||||
export interface RegisterContextOptions {
|
||||
/** Unique ID for this context entry (used for deduplication) */
|
||||
id: string
|
||||
/** Source identifier */
|
||||
source: ContextSourceType
|
||||
/** The content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority?: ContextPriority
|
||||
/** Optional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getting pending context for a session
|
||||
*/
|
||||
export interface PendingContext {
|
||||
/** Merged context string, ready for injection */
|
||||
merged: string
|
||||
/** Individual entries that were merged */
|
||||
entries: ContextEntry[]
|
||||
/** Whether there's any content to inject */
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Message context from the original user message
|
||||
* Used when injecting to match the message format
|
||||
*/
|
||||
export interface MessageContext {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
root?: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Output parts from chat.message hook
|
||||
*/
|
||||
export interface OutputParts {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection strategy
|
||||
*/
|
||||
export type InjectionStrategy = "prepend-parts" | "storage" | "auto"
|
||||
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import type { LoadedSkill } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "async-loader-test-" + Date.now())
|
||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||
|
||||
function createTestSkill(name: string, content: string, mcpJson?: object): string {
|
||||
const skillDir = join(SKILLS_DIR, name)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
writeFileSync(skillPath, content)
|
||||
if (mcpJson) {
|
||||
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||
}
|
||||
return skillDir
|
||||
}
|
||||
|
||||
function createDirectSkill(name: string, content: string): string {
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const skillPath = join(SKILLS_DIR, `${name}.md`)
|
||||
writeFileSync(skillPath, content)
|
||||
return skillPath
|
||||
}
|
||||
|
||||
describe("async-loader", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverSkillsInDirAsync", () => {
|
||||
it("returns empty array for non-existent directory", async () => {
|
||||
// #given - non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(nonExistentDir)
|
||||
|
||||
// #then - should return empty array, not throw
|
||||
expect(skills).toEqual([])
|
||||
})
|
||||
|
||||
it("discovers skills from SKILL.md in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is the skill body.
|
||||
`
|
||||
createTestSkill("test-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("A test skill")
|
||||
})
|
||||
|
||||
it("discovers skills from {name}.md pattern in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: named-skill
|
||||
description: Named pattern skill
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const skillDir = join(SKILLS_DIR, "named-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "named-skill.md"), skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("named-skill")
|
||||
})
|
||||
|
||||
it("discovers direct .md files", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: direct-skill
|
||||
description: Direct markdown file
|
||||
---
|
||||
Direct skill.
|
||||
`
|
||||
createDirectSkill("direct-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("direct-skill")
|
||||
})
|
||||
|
||||
it("skips entries starting with dot", async () => {
|
||||
// #given
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid.
|
||||
`
|
||||
const hiddenContent = `---
|
||||
name: hidden-skill
|
||||
---
|
||||
Hidden.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
createTestSkill(".hidden-skill", hiddenContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - only valid-skill should be discovered
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0]?.name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("skips invalid files and continues with valid ones", async () => {
|
||||
// #given - one valid, one invalid (unreadable)
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid skill.
|
||||
`
|
||||
const invalidContent = `---
|
||||
name: invalid-skill
|
||||
---
|
||||
Invalid skill.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
const invalidDir = createTestSkill("invalid-skill", invalidContent)
|
||||
const invalidFile = join(invalidDir, "SKILL.md")
|
||||
|
||||
// Make file unreadable on Unix systems
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o000)
|
||||
}
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - should skip invalid and return only valid
|
||||
expect(skills.length).toBeGreaterThanOrEqual(1)
|
||||
expect(skills.some((s: LoadedSkill) => s.name === "valid-skill")).toBe(true)
|
||||
|
||||
// Cleanup: restore permissions before cleanup
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it("discovers multiple skills correctly", async () => {
|
||||
// #given
|
||||
const skill1 = `---
|
||||
name: skill-one
|
||||
description: First skill
|
||||
---
|
||||
Skill one.
|
||||
`
|
||||
const skill2 = `---
|
||||
name: skill-two
|
||||
description: Second skill
|
||||
---
|
||||
Skill two.
|
||||
`
|
||||
createTestSkill("skill-one", skill1)
|
||||
createTestSkill("skill-two", skill2)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const asyncSkills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(asyncSkills.length).toBe(2)
|
||||
expect(asyncSkills.map((s: LoadedSkill) => s.name).sort()).toEqual(["skill-one", "skill-two"])
|
||||
|
||||
const skill1Result = asyncSkills.find((s: LoadedSkill) => s.name === "skill-one")
|
||||
expect(skill1Result?.definition.description).toContain("First skill")
|
||||
})
|
||||
|
||||
it("loads MCP config from frontmatter", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: mcp-skill
|
||||
description: Skill with MCP
|
||||
mcp:
|
||||
sqlite:
|
||||
command: uvx
|
||||
args: [mcp-server-sqlite]
|
||||
---
|
||||
MCP skill.
|
||||
`
|
||||
createTestSkill("mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "mcp-skill")
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
})
|
||||
|
||||
it("loads MCP config from mcp.json file", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: json-mcp-skill
|
||||
description: Skill with mcp.json
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("json-mcp-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "json-mcp-skill")
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
})
|
||||
|
||||
it("prioritizes mcp.json over frontmatter MCP", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: priority-test
|
||||
mcp:
|
||||
from-yaml:
|
||||
command: yaml-cmd
|
||||
---
|
||||
Skill.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
"from-json": {
|
||||
command: "json-cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("priority-test", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "priority-test")
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapWithConcurrency", () => {
|
||||
it("processes items with concurrency limit", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
const items = Array.from({ length: 50 }, (_, i) => i)
|
||||
let maxConcurrent = 0
|
||||
let currentConcurrent = 0
|
||||
|
||||
const mapper = async (item: number) => {
|
||||
currentConcurrent++
|
||||
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
currentConcurrent--
|
||||
return item * 2
|
||||
}
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency(items, mapper, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual(items.map(i => i * 2))
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(16)
|
||||
expect(maxConcurrent).toBeGreaterThan(1) // Should actually run concurrently
|
||||
})
|
||||
|
||||
it("handles empty array", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it("handles single item", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([42], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([84])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSkillFromPathAsync", () => {
|
||||
it("loads skill from valid path", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: path-skill
|
||||
description: Loaded from path
|
||||
---
|
||||
Path skill.
|
||||
`
|
||||
const skillDir = createTestSkill("path-skill", skillContent)
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(skillPath, skillDir, "path-skill", "opencode-project")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull()
|
||||
expect(skill?.name).toBe("path-skill")
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("returns null for invalid path", async () => {
|
||||
// #given
|
||||
const invalidPath = join(TEST_DIR, "nonexistent.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(invalidPath, TEST_DIR, "invalid", "opencode")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for malformed skill file", async () => {
|
||||
// #given
|
||||
const malformedContent = "This is not valid frontmatter content\nNo YAML here!"
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const malformedPath = join(SKILLS_DIR, "malformed.md")
|
||||
writeFileSync(malformedPath, malformedContent)
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(malformedPath, SKILLS_DIR, "malformed", "user")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull() // parseFrontmatter handles missing frontmatter gracefully
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadMcpJsonFromDirAsync", () => {
|
||||
it("loads mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
args: ["arg1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.test).toBeDefined()
|
||||
expect(config?.test?.command).toBe("test-cmd")
|
||||
})
|
||||
|
||||
it("returns undefined for non-existent mcp.json", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for invalid JSON", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), "{ invalid json }")
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
direct: {
|
||||
command: "direct-cmd",
|
||||
args: ["arg"]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config?.direct).toBeDefined()
|
||||
expect(config?.direct?.command).toBe("direct-cmd")
|
||||
})
|
||||
})
|
||||
})
|
||||
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { readFile, readdir } from "fs/promises"
|
||||
import type { Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let index = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
results[currentIndex] = await mapper(items[currentIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!frontmatterMatch) return undefined
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||
return parsed.mcp as SkillMcpConfig
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadMcpJsonFromDirAsync(skillDir: string): Promise<SkillMcpConfig | undefined> {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
|
||||
try {
|
||||
const content = await readFile(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
)
|
||||
if (hasCommandField) {
|
||||
return parsed as SkillMcpConfig
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadSkillFromPathAsync(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await readFile(skillPath, "utf-8")
|
||||
const { data, body, parseError } = parseFrontmatter<SkillMetadata>(content)
|
||||
if (parseError) return null
|
||||
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function discoverSkillsInDirAsync(skillsDir: string): Promise<LoadedSkill[]> {
|
||||
try {
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
|
||||
const processEntry = async (entry: Dirent): Promise<LoadedSkill | null> => {
|
||||
if (entry.name.startsWith(".")) return null
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await readFile(skillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await readFile(namedSkillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
|
||||
return skillPromises.filter((skill): skill is LoadedSkill => skill !== null)
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { discoverAllSkillsBlocking } from "./blocking"
|
||||
import type { SkillScope } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `blocking-test-${Date.now()}`)
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverAllSkillsBlocking", () => {
|
||||
it("returns skills synchronously from valid directories", () => {
|
||||
// #given valid skill directory
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(skillDir, "test-skill.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is test skill content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skills synchronously
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("test skill")
|
||||
})
|
||||
|
||||
it("returns empty array for empty directories", () => {
|
||||
// #given empty directory
|
||||
const emptyDir = join(TEST_DIR, "empty")
|
||||
mkdirSync(emptyDir, { recursive: true })
|
||||
|
||||
const dirs = [emptyDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("returns empty array for non-existent directories", () => {
|
||||
// #given non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
const dirs = [nonExistentDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array (no throw)
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("handles multiple directories with mixed content", () => {
|
||||
// #given multiple directories with valid and invalid skills
|
||||
const dir1 = join(TEST_DIR, "dir1")
|
||||
const dir2 = join(TEST_DIR, "dir2")
|
||||
mkdirSync(dir1, { recursive: true })
|
||||
mkdirSync(dir2, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(dir1, "skill1.md"),
|
||||
`---
|
||||
name: skill1
|
||||
description: First skill
|
||||
---
|
||||
Skill 1 content.`
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
join(dir2, "skill2.md"),
|
||||
`---
|
||||
name: skill2
|
||||
description: Second skill
|
||||
---
|
||||
Skill 2 content.`
|
||||
)
|
||||
|
||||
const dirs = [dir1, dir2]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns all valid skills
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(2)
|
||||
|
||||
const skillNames = skills.map(s => s.name).sort()
|
||||
expect(skillNames).toEqual(["skill1", "skill2"])
|
||||
})
|
||||
|
||||
it("skips invalid YAML files", () => {
|
||||
// #given directory with invalid YAML
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const validSkillPath = join(skillDir, "valid.md")
|
||||
writeFileSync(
|
||||
validSkillPath,
|
||||
`---
|
||||
name: valid-skill
|
||||
description: Valid skill
|
||||
---
|
||||
Valid skill content.`
|
||||
)
|
||||
|
||||
const invalidSkillPath = join(skillDir, "invalid.md")
|
||||
writeFileSync(
|
||||
invalidSkillPath,
|
||||
`---
|
||||
name: invalid skill
|
||||
description: [ invalid yaml
|
||||
---
|
||||
Invalid content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then skips invalid, returns valid
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("handles directory-based skills with SKILL.md", () => {
|
||||
// #given directory-based skill structure
|
||||
const skillsDir = join(TEST_DIR, "skills")
|
||||
const mySkillDir = join(skillsDir, "my-skill")
|
||||
mkdirSync(mySkillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(mySkillDir, "SKILL.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: my-skill
|
||||
description: Directory-based skill
|
||||
---
|
||||
This is a directory-based skill.`
|
||||
)
|
||||
|
||||
const dirs = [skillsDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skill from SKILL.md
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("my-skill")
|
||||
})
|
||||
|
||||
it("processes large skill sets without timeout", () => {
|
||||
// #given directory with many skills (20+)
|
||||
const skillDir = join(TEST_DIR, "many-skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillCount = 25
|
||||
for (let i = 0; i < skillCount; i++) {
|
||||
const skillPath = join(skillDir, `skill-${i}.md`)
|
||||
writeFileSync(
|
||||
skillPath,
|
||||
`---
|
||||
name: skill-${i}
|
||||
description: Skill number ${i}
|
||||
---
|
||||
Content for skill ${i}.`
|
||||
)
|
||||
}
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then completes without timeout
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(skillCount)
|
||||
})
|
||||
})
|
||||
62
src/features/opencode-skill-loader/blocking.ts
Normal file
62
src/features/opencode-skill-loader/blocking.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const TIMEOUT_MS = 30000
|
||||
|
||||
export function discoverAllSkillsBlocking(dirs: string[], scopes: SkillScope[]): LoadedSkill[] {
|
||||
const signal = new Int32Array(new SharedArrayBuffer(4))
|
||||
const { port1, port2 } = new MessageChannel()
|
||||
|
||||
const worker = new Worker(new URL("./discover-worker.ts", import.meta.url), {
|
||||
workerData: { signal }
|
||||
})
|
||||
|
||||
worker.postMessage({ port: port2 }, [port2])
|
||||
|
||||
const input: WorkerInput = { dirs, scopes }
|
||||
port1.postMessage(input)
|
||||
|
||||
const waitResult = Atomics.wait(signal, 0, 0, TIMEOUT_MS)
|
||||
|
||||
if (waitResult === "timed-out") {
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
throw new Error(`Worker timeout after ${TIMEOUT_MS}ms`)
|
||||
}
|
||||
|
||||
const message = receiveMessageOnPort(port1)
|
||||
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
|
||||
if (!message) {
|
||||
throw new Error("Worker did not return result")
|
||||
}
|
||||
|
||||
const output = message.message as WorkerOutput
|
||||
|
||||
if (output.ok === false) {
|
||||
const error = new Error(output.error.message)
|
||||
error.stack = output.error.stack
|
||||
throw error
|
||||
}
|
||||
|
||||
return output.skills
|
||||
}
|
||||
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { workerData, parentPort } from "worker_threads"
|
||||
import type { MessagePort } from "worker_threads"
|
||||
import { discoverSkillsInDirAsync } from "./async-loader"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const { signal } = workerData as { signal: Int32Array }
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error("Worker must be run with parentPort")
|
||||
}
|
||||
|
||||
parentPort.once("message", (data: { port: MessagePort }) => {
|
||||
const { port } = data
|
||||
|
||||
port.on("message", async (input: WorkerInput) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
input.dirs.map(dir => discoverSkillsInDirAsync(dir))
|
||||
)
|
||||
|
||||
const skills = results.flat()
|
||||
|
||||
const output: WorkerOutputSuccess = { ok: true, skills }
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
} catch (error: unknown) {
|
||||
const output: WorkerOutputError = {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -134,7 +134,7 @@ Skill with env vars.
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given
|
||||
// #given - malformed YAML causes entire frontmatter to fail parsing
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
@@ -150,9 +150,9 @@ Skill body.
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "bad-yaml")
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
// #then - should still load skill but without MCP config
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { promises as fs } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
@@ -7,7 +8,7 @@ import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
@@ -66,7 +67,7 @@ function loadSkillFromPath(
|
||||
): LoadedSkill | null {
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
const { data } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
@@ -76,7 +77,15 @@ function loadSkillFromPath(
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
// Lazy content loader - only loads template on first use
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
@@ -86,11 +95,16 @@ ${body.trim()}
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
template: "", // Empty at startup, loaded lazily
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
@@ -108,6 +122,76 @@ $ARGUMENTS
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkillFromPathAsync(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await fs.readFile(skillPath, "utf-8")
|
||||
const { data } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: "",
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -164,6 +248,53 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of loadSkillsFromDir using Promise-based fs operations.
|
||||
*/
|
||||
async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const skill of skills) {
|
||||
@@ -286,3 +417,41 @@ export function discoverOpencodeProjectSkills(): LoadedSkill[] {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
export async function discoverUserClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDirAsync(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDirAsync(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGlobalSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
const { includeClaudeCodePaths = true } = options
|
||||
|
||||
const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync()
|
||||
const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync()
|
||||
|
||||
if (!includeClaudeCodePaths) {
|
||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
||||
}
|
||||
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
discoverProjectClaudeSkillsAsync(),
|
||||
discoverUserClaudeSkillsAsync(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface SkillMetadata {
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
export interface LazyContentLoader {
|
||||
loaded: boolean
|
||||
content?: string
|
||||
load: () => Promise<string>
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
path?: string
|
||||
@@ -28,4 +34,5 @@ export interface LoadedSkill {
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
mcpConfig?: SkillMcpConfig
|
||||
lazyContent?: LazyContentLoader
|
||||
}
|
||||
|
||||
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createCleanMcpEnvironment, EXCLUDED_ENV_PATTERNS } from "./env-cleaner"
|
||||
|
||||
describe("createCleanMcpEnvironment", () => {
|
||||
// Store original env to restore after tests
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
describe("NPM_CONFIG_* filtering", () => {
|
||||
it("filters out uppercase NPM_CONFIG_* variables", () => {
|
||||
// #given
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
process.env.NPM_CONFIG_CACHE = "/some/cache/path"
|
||||
process.env.NPM_CONFIG_PREFIX = "/some/prefix"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined()
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
})
|
||||
|
||||
it("filters out lowercase npm_config_* variables", () => {
|
||||
// #given
|
||||
process.env.npm_config_registry = "https://private.registry.com"
|
||||
process.env.npm_config_cache = "/some/cache/path"
|
||||
process.env.npm_config_https_proxy = "http://proxy:8080"
|
||||
process.env.npm_config_proxy = "http://proxy:8080"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_https_proxy).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_proxy).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
})
|
||||
|
||||
describe("YARN_* filtering", () => {
|
||||
it("filters out YARN_* variables", () => {
|
||||
// #given
|
||||
process.env.YARN_CACHE_FOLDER = "/yarn/cache"
|
||||
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true"
|
||||
process.env.YARN_REGISTRY = "https://yarn.registry.com"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined()
|
||||
expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined()
|
||||
expect(cleanEnv.YARN_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PNPM_* filtering", () => {
|
||||
it("filters out PNPM_* variables", () => {
|
||||
// #given
|
||||
process.env.PNPM_HOME = "/pnpm/home"
|
||||
process.env.PNPM_STORE_DIR = "/pnpm/store"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PNPM_HOME).toBeUndefined()
|
||||
expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
})
|
||||
})
|
||||
|
||||
describe("NO_UPDATE_NOTIFIER filtering", () => {
|
||||
it("filters out NO_UPDATE_NOTIFIER variable", () => {
|
||||
// #given
|
||||
process.env.NO_UPDATE_NOTIFIER = "1"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
})
|
||||
|
||||
describe("custom environment overlay", () => {
|
||||
it("merges custom env on top of clean process.env", () => {
|
||||
// #given
|
||||
process.env.PATH = "/usr/bin"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
const customEnv = {
|
||||
MCP_API_KEY: "secret-key",
|
||||
CUSTOM_VAR: "custom-value",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.MCP_API_KEY).toBe("secret-key")
|
||||
expect(cleanEnv.CUSTOM_VAR).toBe("custom-value")
|
||||
})
|
||||
|
||||
it("custom env can override process.env values", () => {
|
||||
// #given
|
||||
process.env.NODE_ENV = "development"
|
||||
const customEnv = {
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("undefined value handling", () => {
|
||||
it("skips undefined values from process.env", () => {
|
||||
// #given - process.env can have undefined values in TypeScript
|
||||
const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined }
|
||||
Object.assign(process.env, envWithUndefined)
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then - should not throw and should not include undefined values
|
||||
expect(cleanEnv.UNDEFINED_VAR).toBeUndefined()
|
||||
expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mixed case handling", () => {
|
||||
it("filters both uppercase and lowercase npm config variants", () => {
|
||||
// #given - pnpm/yarn can set both cases simultaneously
|
||||
process.env.NPM_CONFIG_CACHE = "/uppercase/cache"
|
||||
process.env.npm_config_cache = "/lowercase/cache"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com"
|
||||
process.env.npm_config_registry = "https://lowercase.registry.com"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
it("contains patterns for npm, yarn, and pnpm configs", () => {
|
||||
// #given / #when / #then
|
||||
expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// Test that patterns match expected strings
|
||||
const testCases = [
|
||||
{ pattern: "NPM_CONFIG_REGISTRY", shouldMatch: true },
|
||||
{ pattern: "npm_config_registry", shouldMatch: true },
|
||||
{ pattern: "YARN_CACHE_FOLDER", shouldMatch: true },
|
||||
{ pattern: "PNPM_HOME", shouldMatch: true },
|
||||
{ pattern: "NO_UPDATE_NOTIFIER", shouldMatch: true },
|
||||
{ pattern: "PATH", shouldMatch: false },
|
||||
{ pattern: "HOME", shouldMatch: false },
|
||||
{ pattern: "NODE_ENV", shouldMatch: false },
|
||||
]
|
||||
|
||||
for (const { pattern, shouldMatch } of testCases) {
|
||||
const matches = EXCLUDED_ENV_PATTERNS.some((regex: RegExp) => regex.test(pattern))
|
||||
expect(matches).toBe(shouldMatch)
|
||||
}
|
||||
})
|
||||
})
|
||||
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
||||
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||
/^NPM_CONFIG_/i,
|
||||
/^npm_config_/,
|
||||
/^YARN_/,
|
||||
/^PNPM_/,
|
||||
/^NO_UPDATE_NOTIFIER$/,
|
||||
]
|
||||
|
||||
export function createCleanMcpEnvironment(
|
||||
customEnv: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const cleanEnv: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue
|
||||
|
||||
const shouldExclude = EXCLUDED_ENV_PATTERNS.some((pattern) => pattern.test(key))
|
||||
if (!shouldExclude) {
|
||||
cleanEnv[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(cleanEnv, customEnv)
|
||||
|
||||
return cleanEnv
|
||||
}
|
||||
@@ -106,4 +106,54 @@ describe("SkillMcpManager", () => {
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment variable handling", () => {
|
||||
it("always inherits process.env even when config.env is undefined", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const configWithoutEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when - attempt connection (will fail but exercises env merging code path)
|
||||
// #then - should not throw "undefined" related errors for env
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithoutEnv)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
expect(message).not.toContain("env")
|
||||
expect(message).not.toContain("undefined")
|
||||
}
|
||||
})
|
||||
|
||||
it("overlays config.env on top of inherited process.env", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-2",
|
||||
}
|
||||
const configWithEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
env: {
|
||||
CUSTOM_VAR: "custom_value",
|
||||
},
|
||||
}
|
||||
|
||||
// #when - attempt connection
|
||||
// #then - should not throw, env merging should work
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithEnv)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
expect(message).toContain("Failed to connect")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,21 +3,68 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
private pendingConnections: Map<string, Promise<Client>> = new Map()
|
||||
private cleanupRegistered = false
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
private getClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
if (this.cleanupRegistered) return
|
||||
this.cleanupRegistered = true
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const [, managed] of this.clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
this.pendingConnections.clear()
|
||||
}
|
||||
|
||||
// Note: 'exit' event is synchronous-only in Node.js, so we use 'beforeExit' for async cleanup
|
||||
// However, 'beforeExit' is not emitted on explicit process.exit() calls
|
||||
// Signal handlers are made async to properly await cleanup
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
process.on("SIGTERM", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
if (process.platform === "win32") {
|
||||
process.on("SIGBREAK", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
@@ -26,12 +73,26 @@ export class SkillMcpManager {
|
||||
const existing = this.clients.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.lastUsedAt = Date.now()
|
||||
return existing.client
|
||||
}
|
||||
|
||||
// Prevent race condition: if a connection is already in progress, wait for it
|
||||
const pending = this.pendingConnections.get(key)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const client = await this.createClient(info, expandedConfig)
|
||||
return client
|
||||
const connectionPromise = this.createClient(info, expandedConfig)
|
||||
this.pendingConnections.set(key, connectionPromise)
|
||||
|
||||
try {
|
||||
const client = await connectionPromise
|
||||
return client
|
||||
} finally {
|
||||
this.pendingConnections.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async createClient(
|
||||
@@ -55,18 +116,15 @@ export class SkillMcpManager {
|
||||
const command = config.command
|
||||
const args = config.args || []
|
||||
|
||||
const mergedEnv: Record<string, string> = {}
|
||||
if (config.env) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
const mergedEnv = createCleanMcpEnvironment(config.env)
|
||||
|
||||
this.registerProcessCleanup()
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: config.env ? mergedEnv : undefined,
|
||||
env: mergedEnv,
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
@@ -77,6 +135,12 @@ export class SkillMcpManager {
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
// Close transport to prevent orphaned MCP process on connection failure
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// Process may already be terminated
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
@@ -89,7 +153,8 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName })
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -99,26 +164,64 @@ export class SkillMcpManager {
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
if (key.startsWith(`${sessionID}:`)) {
|
||||
keysToRemove.push(key)
|
||||
// Delete from map first to prevent re-entrancy during async close
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore close errors - process may already be terminated
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [, managed] of this.clients.entries()) {
|
||||
this.stopCleanupTimer()
|
||||
const clients = Array.from(this.clients.values())
|
||||
this.clients.clear()
|
||||
for (const managed of clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
|
||||
private startCleanupTimer(): void {
|
||||
if (this.cleanupInterval) return
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupIdleClients()
|
||||
}, 60_000)
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
|
||||
private stopCleanupTimer(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupIdleClients(): Promise<void> {
|
||||
const now = Date.now()
|
||||
for (const [key, managed] of this.clients) {
|
||||
if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
async listTools(
|
||||
@@ -190,10 +293,13 @@ export class SkillMcpManager {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
return await this.getOrCreateClient(info, config)
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -2,85 +2,65 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
|
||||
22 lifecycle hooks intercepting/modifying agent behavior. Context injection, error recovery, output control, notifications.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Remind to use specialized agents
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
|
||||
├── auto-slash-command/ # Auto-detect and execute /command patterns
|
||||
├── auto-update-checker/ # Version update notifications
|
||||
├── background-notification/ # OS notify on background task complete
|
||||
├── claude-code-hooks/ # Claude Code settings.json integration
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact at token limit (554 lines)
|
||||
├── auto-slash-command/ # Detect and execute /command patterns
|
||||
├── auto-update-checker/ # Version notifications, startup toast
|
||||
├── background-notification/ # OS notify on task complete
|
||||
├── claude-code-hooks/ # settings.json PreToolUse/PostToolUse/etc
|
||||
├── comment-checker/ # Prevent excessive AI comments
|
||||
│ ├── filters/ # Filtering rules (docstring, directive, bdd, etc.)
|
||||
│ └── output/ # Output formatting
|
||||
├── compaction-context-injector/ # Inject context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-inject README.md files
|
||||
│ └── filters/ # docstring, directive, bdd, etc
|
||||
├── compaction-context-injector/ # Preserve context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md
|
||||
├── directory-readme-injector/ # Auto-inject README.md
|
||||
├── empty-message-sanitizer/ # Sanitize empty messages
|
||||
├── interactive-bash-session/ # Tmux session management
|
||||
├── keyword-detector/ # Detect ultrawork/search keywords
|
||||
├── non-interactive-env/ # CI/headless environment handling
|
||||
├── preemptive-compaction/ # Pre-emptive session compaction
|
||||
├── ralph-loop/ # Self-referential dev loop until completion
|
||||
├── keyword-detector/ # ultrawork/search keyword activation
|
||||
├── non-interactive-env/ # CI/headless handling
|
||||
├── preemptive-compaction/ # Pre-emptive at 85% usage
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── session-recovery/ # Recover from errors (430 lines)
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── thinking-block-validator/ # Validate thinking blocks in messages
|
||||
├── context-window-monitor.ts # Monitor context usage (standalone)
|
||||
├── empty-task-response-detector.ts
|
||||
├── session-notification.ts # OS notify on idle (standalone)
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion (standalone)
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
|
||||
├── agent-usage-reminder/ # Remind to use specialists
|
||||
├── context-window-monitor.ts # Monitor usage (standalone)
|
||||
├── session-notification.ts # OS notify on idle
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs
|
||||
```
|
||||
|
||||
## HOOK CATEGORIES
|
||||
|
||||
| Category | Hooks | Purpose |
|
||||
|----------|-------|---------|
|
||||
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
|
||||
| Session Management | session-recovery, anthropic-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
|
||||
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
|
||||
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
|
||||
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
|
||||
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
|
||||
| Compatibility | claude-code-hooks | Claude Code settings.json support |
|
||||
|
||||
## HOW TO ADD A HOOK
|
||||
|
||||
1. Create directory: `src/hooks/my-hook/`
|
||||
2. Create files:
|
||||
- `index.ts`: Export `createMyHook(input: PluginInput)`
|
||||
- `constants.ts`: Hook name constant
|
||||
- `types.ts`: TypeScript interfaces (optional)
|
||||
- `storage.ts`: Persistent state (optional)
|
||||
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
5. Register in main plugin
|
||||
|
||||
## HOOK EVENTS
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool exec | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool exec | No | Add context, warnings |
|
||||
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
|
||||
| PreToolUse | Before tool | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool | No | Add context, warnings |
|
||||
| UserPromptSubmit | On prompt | Yes | Inject messages, block |
|
||||
| Stop | Session idle | No | Inject follow-ups |
|
||||
| onSummarize | During compaction | No | Preserve critical context |
|
||||
| onSummarize | Compaction | No | Preserve context |
|
||||
|
||||
## COMMON PATTERNS
|
||||
## HOW TO ADD
|
||||
|
||||
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
|
||||
- **Message injection**: Return `{ messages: [...] }` from event handlers
|
||||
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
|
||||
1. Create `src/hooks/my-hook/`
|
||||
2. Files: `index.ts` (createMyHook), `constants.ts`, `types.ts` (optional)
|
||||
3. Return: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
|
||||
## ANTI-PATTERNS (HOOKS)
|
||||
## PATTERNS
|
||||
|
||||
- **Heavy computation** in PreToolUse: Slows every tool call
|
||||
- **Blocking without clear reason**: Always provide actionable message
|
||||
- **Duplicate injection**: Track what's already injected per session
|
||||
- **Ignoring errors**: Always try/catch, log failures, don't crash session
|
||||
- **Storage**: JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set
|
||||
- **Message injection**: Return `{ messages: [...] }`
|
||||
- **Blocking**: Return `{ blocked: true, message: "..." }` from PreToolUse
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Heavy computation in PreToolUse (slows every tool call)
|
||||
- Blocking without actionable message
|
||||
- Duplicate injection (track what's injected)
|
||||
- Missing try/catch (don't crash session)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import * as storage from "./storage"
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
@@ -224,4 +225,86 @@ describe("executeCompact lock management", () => {
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("falls through to summarize when truncation is insufficient", async () => {
|
||||
// #given: Over token limit with truncation returning insufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: false,
|
||||
truncatedCount: 3,
|
||||
totalBytesRemoved: 10000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 5000 },
|
||||
{ toolName: "Read", originalSize: 3000 },
|
||||
{ toolName: "Bash", originalSize: 2000 },
|
||||
],
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should be called (fall through from insufficient truncation)
|
||||
expect(mockClient.session.summarize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does NOT call summarize when truncation is sufficient", async () => {
|
||||
// #given: Over token limit with truncation returning sufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 5,
|
||||
totalBytesRemoved: 60000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 30000 },
|
||||
{ toolName: "Read", originalSize: 30000 },
|
||||
],
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should NOT be called (early return from sufficient truncation)
|
||||
expect(mockClient.session.summarize).not.toHaveBeenCalled()
|
||||
|
||||
// #then: prompt_async should be called (Continue after successful truncation)
|
||||
expect(mockClient.session.prompt_async).toHaveBeenCalled()
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,7 +39,7 @@ type Client = {
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt_async: (opts: {
|
||||
path: { sessionID: string };
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: string; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
@@ -401,21 +401,31 @@ export async function executeCompact(
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
// Only return early if truncation was sufficient to get under token limit
|
||||
// Otherwise fall through to PHASE 3 (Summarize)
|
||||
if (aggressiveResult.sufficient) {
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
// Truncation was insufficient - fall through to Summarize
|
||||
log("[auto-compact] truncation insufficient, falling through to summarize", {
|
||||
sessionID,
|
||||
truncatedCount: aggressiveResult.truncatedCount,
|
||||
sufficient: aggressiveResult.sufficient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 3: Summarize - fallback when no tool outputs to truncate
|
||||
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
@@ -496,7 +506,7 @@ export async function executeCompact(
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
@@ -26,9 +26,23 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
// Patterns that indicate thinking block structure errors (NOT token limit errors)
|
||||
// These should be handled by session-recovery hook, not compaction
|
||||
const THINKING_BLOCK_ERROR_PATTERNS = [
|
||||
/thinking.*first block/i,
|
||||
/first block.*thinking/i,
|
||||
/must.*start.*thinking/i,
|
||||
/thinking.*redacted_thinking/i,
|
||||
/expected.*thinking.*found/i,
|
||||
/thinking.*disabled.*cannot.*contain/i,
|
||||
]
|
||||
|
||||
function isThinkingBlockError(text: string): boolean {
|
||||
return THINKING_BLOCK_ERROR_PATTERNS.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
@@ -52,6 +66,9 @@ function extractMessageIndex(text: string): number | undefined {
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
if (isThinkingBlockError(text)) {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { truncateUntilTargetTokens } from "./storage"
|
||||
import * as storage from "./storage"
|
||||
|
||||
// Mock the entire module
|
||||
mock.module("./storage", () => {
|
||||
return {
|
||||
...storage,
|
||||
findToolResultsBySize: mock(() => []),
|
||||
truncateToolResult: mock(() => ({ success: false })),
|
||||
}
|
||||
})
|
||||
|
||||
describe("truncateUntilTargetTokens", () => {
|
||||
const sessionID = "test-session"
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
findToolResultsBySize.mockReset()
|
||||
truncateToolResult.mockReset()
|
||||
})
|
||||
|
||||
test("truncates only until target is reached", () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
|
||||
]
|
||||
|
||||
findToolResultsBySize.mockReturnValue(results)
|
||||
truncateToolResult.mockImplementation((path: string) => ({
|
||||
success: true,
|
||||
toolName: path === "path1" ? "tool1" : "tool2",
|
||||
originalSize: 1000
|
||||
}))
|
||||
|
||||
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
|
||||
// charsPerToken=1 for simplicity in test
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should only truncate the first tool
|
||||
expect(result.truncatedCount).toBe(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledWith("path1")
|
||||
expect(result.totalBytesRemoved).toBe(1000)
|
||||
expect(result.sufficient).toBe(true)
|
||||
})
|
||||
|
||||
test("truncates all if target not reached", () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
|
||||
]
|
||||
|
||||
findToolResultsBySize.mockReturnValue(results)
|
||||
truncateToolResult.mockImplementation((path: string) => ({
|
||||
success: true,
|
||||
toolName: path === "path1" ? "tool1" : "tool2",
|
||||
originalSize: 100
|
||||
}))
|
||||
|
||||
// #when: reduce 500 chars
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should truncate both
|
||||
expect(result.truncatedCount).toBe(2)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(2)
|
||||
expect(result.totalBytesRemoved).toBe(200)
|
||||
expect(result.sufficient).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -230,6 +230,10 @@ export function truncateUntilTargetTokens(
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
|
||||
if (totalRemoved >= charsToReduce) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,5 +44,5 @@ export const TRUNCATE_CONFIG = {
|
||||
maxTruncateAttempts: 20,
|
||||
minOutputSizeToTruncate: 500,
|
||||
targetTokenRatio: 0.5,
|
||||
charsPerToken: 2,
|
||||
charsPerToken: 4,
|
||||
} as const
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
sanitizeModelField,
|
||||
getClaudeConfigDir,
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
@@ -49,7 +50,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
USER_CONFIG_DIR,
|
||||
getWindowsAppdataDir,
|
||||
} from "./constants"
|
||||
import * as os from "node:os"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
@@ -23,12 +26,32 @@ function stripJsonComments(json: string): string {
|
||||
}
|
||||
|
||||
function getConfigPaths(directory: string): string[] {
|
||||
return [
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
]
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = getWindowsAppdataDir()
|
||||
|
||||
if (appdataDir) {
|
||||
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
|
||||
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
|
||||
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (!paths.includes(alternateConfig)) {
|
||||
paths.push(alternateConfig)
|
||||
}
|
||||
if (!paths.includes(alternateConfigJsonc)) {
|
||||
paths.push(alternateConfigJsonc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import * as fs from "node:fs"
|
||||
|
||||
export const PACKAGE_NAME = "oh-my-opencode"
|
||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
||||
@@ -28,14 +29,36 @@ export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
|
||||
/**
|
||||
* OpenCode config file locations (priority order)
|
||||
* On Windows, checks ~/.config first (cross-platform), then %APPDATA% (fallback)
|
||||
* This matches shared/config-path.ts behavior for consistency
|
||||
*/
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
|
||||
// Check cross-platform path first (~/.config)
|
||||
const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
|
||||
const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
|
||||
return crossPlatformDir
|
||||
}
|
||||
|
||||
// Fall back to %APPDATA%
|
||||
return appdataDir
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Windows-specific APPDATA directory (for fallback checks)
|
||||
*/
|
||||
export function getWindowsAppdataDir(): string | null {
|
||||
if (process.platform !== "win32") return null
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
}
|
||||
|
||||
export const USER_CONFIG_DIR = getUserConfigDir()
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
|
||||
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invalidatePackage } from "./cache"
|
||||
import { PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
|
||||
import { runBunInstall } from "../../cli/config-manager"
|
||||
import type { AutoUpdateCheckerOptions } from "./types"
|
||||
|
||||
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
|
||||
@@ -100,16 +101,34 @@ async function runBackgroundUpdateCheck(
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
|
||||
if (updated) {
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
|
||||
} else {
|
||||
if (!updated) {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] Failed to update pinned version in config")
|
||||
return
|
||||
}
|
||||
log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
|
||||
}
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
|
||||
} else {
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
|
||||
}
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
try {
|
||||
return await runBunInstall()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
log("[auto-update-checker] bun install error:", errorMessage)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage }
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
@@ -112,11 +113,6 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
@@ -142,27 +138,48 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
return
|
||||
}
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length })
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt))
|
||||
const keywordMessages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
if (keywordMessages.length > 0) {
|
||||
log("[claude-code-hooks] Detected keywords", {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
|
||||
const allMessages = [...keywordMessages, ...result.messages]
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
const hookContent = allMessages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const ANTHROPIC_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
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
|
||||
|
||||
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
|
||||
|
||||
describe("createEditErrorRecoveryHook", () => {
|
||||
let hook: ReturnType<typeof createEditErrorRecoveryHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createEditErrorRecoveryHook({} as any)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Edit",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString/newString same error", () => {
|
||||
describe("#when the error message is detected", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
expect(output.output).toContain("oldString and newString must be different")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the error appears without Error prefix", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString not found error", () => {
|
||||
describe("#when oldString not found in content", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString not found in content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with multiple matches error", () => {
|
||||
describe("#when oldString found multiple times", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput(
|
||||
"Error: oldString found multiple times and requires more code context to uniquely identify the intended match"
|
||||
)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-Edit tool", () => {
|
||||
describe("#when tool is not Edit", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Read")
|
||||
const originalOutput = "some output"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with successful output", () => {
|
||||
describe("#when no error in output", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Edit")
|
||||
const originalOutput = "File edited successfully"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given case insensitive tool name", () => {
|
||||
describe("#when tool is 'edit' lowercase", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_ERROR_PATTERNS", () => {
|
||||
it("#then should contain all known Edit error patterns", () => {
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString not found")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times")
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/hooks/edit-error-recovery/index.ts
Normal file
57
src/hooks/edit-error-recovery/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
/**
|
||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
||||
*/
|
||||
export const EDIT_ERROR_PATTERNS = [
|
||||
"oldString and newString must be different",
|
||||
"oldString not found",
|
||||
"oldString found multiple times",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* System reminder injected when Edit tool fails due to AI mistake
|
||||
* Short, direct, and commanding - forces immediate corrective action
|
||||
*/
|
||||
export const EDIT_ERROR_REMINDER = `
|
||||
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
|
||||
|
||||
You made an Edit mistake. STOP and do this NOW:
|
||||
|
||||
1. READ the file immediately to see its ACTUAL current state
|
||||
2. VERIFY what the content really looks like (your assumption was wrong)
|
||||
3. APOLOGIZE briefly to the user for the error
|
||||
4. CONTINUE with corrected action based on the real file content
|
||||
|
||||
DO NOT attempt another edit until you've read and verified the file state.
|
||||
`
|
||||
|
||||
/**
|
||||
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
|
||||
*
|
||||
* This hook catches common Edit tool failures:
|
||||
* - oldString and newString must be different (trying to "edit" to same content)
|
||||
* - oldString not found (wrong assumption about file content)
|
||||
* - oldString found multiple times (ambiguous match, need more context)
|
||||
*
|
||||
* @see https://github.com/sst/opencode/issues/4718
|
||||
*/
|
||||
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
if (hasEditError) {
|
||||
output.output += `\n${EDIT_ERROR_REMINDER}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
@@ -2,54 +2,89 @@ export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
// ULTRAWORK: ulw, ultrawork
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
## TODO IS YOUR LIFELINE (NON-NEGOTIABLE)
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
**USE TodoWrite OBSESSIVELY. This is the #1 most important tool.**
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
### TODO Rules
|
||||
1. **BEFORE any action**: Create TODOs FIRST. Break down into atomic, granular steps.
|
||||
2. **Be excessively detailed**: 10 small TODOs > 3 vague TODOs. Err on the side of too many.
|
||||
3. **Real-time updates**: Mark \`in_progress\` before starting, \`completed\` IMMEDIATELY after. NEVER batch.
|
||||
4. **One at a time**: Only ONE TODO should be \`in_progress\` at any moment.
|
||||
5. **Sub-tasks**: Complex TODO? Break it into sub-TODOs. Keep granularity high.
|
||||
6. **Questions too**: User asks a question? TODO: "Answer with evidence: [question]"
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
### Example TODO Granularity
|
||||
BAD: "Implement user auth"
|
||||
GOOD:
|
||||
- "Read existing auth patterns in codebase"
|
||||
- "Create auth schema types"
|
||||
- "Implement login endpoint"
|
||||
- "Implement token validation middleware"
|
||||
- "Add auth tests - login success case"
|
||||
- "Add auth tests - login failure case"
|
||||
- "Verify LSP diagnostics clean"
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
**YOUR WORK IS INVISIBLE WITHOUT TODOs. USE THEM.**
|
||||
|
||||
1. Write spec (requirements)
|
||||
2. Write tests (failing)
|
||||
3. RED: tests fail
|
||||
4. Implement minimal code
|
||||
5. GREEN: tests pass
|
||||
6. Refactor if needed (must stay green)
|
||||
7. Next feature, repeat
|
||||
## TDD WORKFLOW (MANDATORY when tests exist)
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
Check for test infrastructure FIRST. If exists, follow strictly:
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
1. **RED**: Write failing test FIRST → \`bun test\` must FAIL
|
||||
2. **GREEN**: Write MINIMAL code to pass → \`bun test\` must PASS
|
||||
3. **REFACTOR**: Clean up, tests stay green → \`bun test\` still PASS
|
||||
4. **REPEAT**: Next test case, loop until complete
|
||||
|
||||
**NEVER write implementation before test. NEVER delete failing tests.**
|
||||
|
||||
## AGENT DEPLOYMENT
|
||||
|
||||
Fire available agents in PARALLEL via background tasks. Use explore/librarian agents liberally (multiple concurrent if needed).
|
||||
|
||||
## EVIDENCE-BASED ANSWERS
|
||||
|
||||
- Every claim: code snippet + file path + line number
|
||||
- No "I think..." - find and SHOW actual code
|
||||
- Local search fails? → librarian for external sources
|
||||
- **NEVER acceptable**: "I couldn't find it"
|
||||
|
||||
## ZERO TOLERANCE FOR SHORTCUTS (RIGOROUS & HONEST EXECUTION)
|
||||
|
||||
**CORE PRINCIPLE**: Execute user's ORIGINAL INTENT with maximum rigor. No shortcuts. No compromises. No matter how large the task.
|
||||
|
||||
### ABSOLUTE PROHIBITIONS
|
||||
| Violation | Why It's Forbidden |
|
||||
|-----------|-------------------|
|
||||
| **Mocking/Stubbing** | Never use mocks, stubs, or fake implementations unless explicitly requested. Real implementation only. |
|
||||
| **Scope Reduction** | Never make "demo", "skeleton", "simplified", "basic", "minimal" versions. Deliver FULL implementation. |
|
||||
| **Partial Completion** | Never stop at 60-80% saying "you can extend this...", "as an exercise...", "you can add...". Finish 100%. |
|
||||
| **Lazy Placeholders** | Never use "// TODO", "...", "etc.", "and so on" in actual code. Complete everything. |
|
||||
| **Assumed Shortcuts** | Never skip requirements deemed "optional" or "can be added later". All requirements are mandatory. |
|
||||
| **Test Deletion** | Never delete or skip failing tests. Fix the code, not the tests. |
|
||||
| **Evidence-Free Claims** | Never say "I think...", "probably...", "should work...". Show actual code/output. |
|
||||
|
||||
### RIGOROUS EXECUTION MANDATE
|
||||
1. **Parse Original Intent**: What did the user ACTUALLY want? Not what's convenient. The REAL, COMPLETE request.
|
||||
2. **No Task Too Large**: If the task requires 100 files, modify 100 files. If it needs 1000 lines, write 1000 lines. Size is irrelevant.
|
||||
3. **Honest Assessment**: If you cannot complete something, say so BEFORE starting. Don't fake completion.
|
||||
4. **Evidence-Based Verification**: Every claim backed by code snippets, file paths, line numbers, and actual outputs.
|
||||
5. **Complete Verification**: Re-read original request after completion. Check EVERY requirement was met.
|
||||
|
||||
### FAILURE RECOVERY
|
||||
If you realize you've taken shortcuts:
|
||||
1. STOP immediately
|
||||
2. Identify what you skipped/faked
|
||||
3. Create TODOs for ALL remaining work
|
||||
4. Execute to TRUE completion - not "good enough"
|
||||
|
||||
**THE USER ASKED FOR X. DELIVER EXACTLY X. COMPLETELY. HONESTLY. NO MATTER THE SIZE.**
|
||||
|
||||
## SUCCESS = All TODOs Done + All Requirements Met + Evidence Provided
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionUltraworkNotified = new Set<string>()
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -24,65 +20,35 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(promptText)
|
||||
const messages = detectedKeywords.map((k) => k.message)
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText))
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
|
||||
sessionUltraworkNotified.add(input.sessionID)
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })
|
||||
)
|
||||
}
|
||||
|
||||
const context = messages.join("\n")
|
||||
|
||||
// First message: transform parts directly (for title generation compatibility)
|
||||
if (isFirstMessage) {
|
||||
log(`Keywords detected on first message, transforming parts directly`, { sessionID: input.sessionID, keywordCount: messages.length })
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${context}\n\n---\n\n${output.parts[idx].text ?? ""}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent messages: inject as separate message
|
||||
log(`Keywords detected: ${messages.length}`, { sessionID: input.sessionID })
|
||||
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
log(`[keyword-detector] Injecting context for ${messages.length} keywords`, { sessionID: input.sessionID, contextLength: context.length })
|
||||
const success = injectHookMessage(input.sessionID, context, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path,
|
||||
tools: message.tools,
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
|
||||
if (success) {
|
||||
log("Keyword context injected", { sessionID: input.sessionID })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ export const NON_INTERACTIVE_ENV: Record<string, string> = {
|
||||
GCM_INTERACTIVE: "never",
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
// Block interactive editors - git rebase, commit, etc.
|
||||
GIT_EDITOR: "true",
|
||||
EDITOR: "true",
|
||||
VISUAL: "true",
|
||||
GIT_SEQUENCE_EDITOR: "true",
|
||||
GIT_EDITOR: ":",
|
||||
EDITOR: ":",
|
||||
VISUAL: "",
|
||||
GIT_SEQUENCE_EDITOR: ":",
|
||||
GIT_MERGE_AUTOEDIT: "no",
|
||||
// Block pagers
|
||||
GIT_PAGER: "cat",
|
||||
PAGER: "cat",
|
||||
|
||||
150
src/hooks/non-interactive-env/index.test.ts
Normal file
150
src/hooks/non-interactive-env/index.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||
|
||||
describe("non-interactive-env hook", () => {
|
||||
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||
|
||||
describe("git command modification", () => {
|
||||
test("#given git command #when hook executes #then prepends export statement", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("GIT_EDITOR=:")
|
||||
expect(cmd).toContain("EDITOR=:")
|
||||
expect(cmd).toContain("PAGER=cat")
|
||||
expect(cmd).toContain("; git commit -m 'test'")
|
||||
})
|
||||
|
||||
test("#given chained git commands #when hook executes #then export applies to all", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git rebase --continue" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git add file && git rebase --continue")
|
||||
})
|
||||
|
||||
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("ls -la")
|
||||
})
|
||||
|
||||
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "Read", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("git status")
|
||||
})
|
||||
|
||||
test("#given empty command #when hook executes #then no error", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell escaping", () => {
|
||||
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("VISUAL=''")
|
||||
})
|
||||
|
||||
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git log" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
|
||||
expect(cmd).toContain(`${key}=`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("banned command detection", () => {
|
||||
test("#given vim command #when hook executes #then warning message set", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "vim file.txt" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toContain("vim")
|
||||
expect(output.message).toContain("interactive")
|
||||
})
|
||||
|
||||
test("#given safe command #when hook executes #then no warning", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,35 @@ function detectBannedCommand(command: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in VAR=value prefix.
|
||||
* Wraps in single quotes if contains special chars.
|
||||
*/
|
||||
function shellEscape(value: string): string {
|
||||
// Empty string needs quotes
|
||||
if (value === "") return "''"
|
||||
// If contains special chars, wrap in single quotes (escape existing single quotes)
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export statement for environment variables.
|
||||
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
|
||||
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
|
||||
*
|
||||
* Previous approach used VAR=value prefix which only applies to the first command.
|
||||
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||
*/
|
||||
function buildEnvPrefix(env: Record<string, string>): string {
|
||||
const exports = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
.join(" ")
|
||||
return `export ${exports};`
|
||||
}
|
||||
|
||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -34,19 +63,27 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
output.args.env = {
|
||||
...(output.args.env as Record<string, string> | undefined),
|
||||
...NON_INTERACTIVE_ENV,
|
||||
}
|
||||
|
||||
const bannedCmd = detectBannedCommand(command)
|
||||
if (bannedCmd) {
|
||||
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
|
||||
// Only prepend env vars for git commands (editor blocking, pager, etc.)
|
||||
const isGitCommand = /\bgit\b/.test(command)
|
||||
if (!isGitCommand) {
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||
// ignoring any args.env we might set. Prepend export statement to command.
|
||||
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
|
||||
// in a chain (e.g., `git add file && git rebase --continue`).
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
sessionID: input.sessionID,
|
||||
env: NON_INTERACTIVE_ENV,
|
||||
envPrefix,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ interface MessageWrapper {
|
||||
}
|
||||
|
||||
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT = 200_000
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
|
||||
function isSupportedModel(modelID: string): boolean {
|
||||
return CLAUDE_MODEL_PATTERN.test(modelID)
|
||||
|
||||
@@ -10,6 +10,8 @@ describe("ralph-loop", () => {
|
||||
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
let messagesCalls: Array<{ sessionID: string }>
|
||||
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
@@ -22,6 +24,10 @@ describe("ralph-loop", () => {
|
||||
})
|
||||
return {}
|
||||
},
|
||||
messages: async (opts: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: opts.path.id })
|
||||
return { data: mockSessionMessages }
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||
@@ -35,12 +41,14 @@ describe("ralph-loop", () => {
|
||||
},
|
||||
},
|
||||
directory: TEST_DIR,
|
||||
} as Parameters<typeof createRalphLoopHook>[0]
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
messagesCalls = []
|
||||
mockSessionMessages = []
|
||||
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
@@ -294,6 +302,77 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should clear orphaned state when original session no longer exists", async () => {
|
||||
// #given - state file exists from a previous session that no longer exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "orphaned-session-999", // This session no longer exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return false for the orphaned session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Orphaned session doesn't exist, current session does
|
||||
return sessionID !== "orphaned-session-999"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a new session goes idle (different from the orphaned session in state)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - orphaned state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
// #then - no continuation injected (state was cleared, not resumed)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should NOT clear state when original session still exists (different active session)", async () => {
|
||||
// #given - state file exists from a session that still exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 2,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "active-session-123", // This session still exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return true for the active session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Original session still exists
|
||||
return sessionID === "active-session-123" || sessionID === "new-session-456"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a different session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - state should NOT be cleared (original session still active)
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
expect(hook.getState()?.session_id).toBe("active-session-123")
|
||||
// #then - no continuation injected (it's a different session's loop)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should use default config values", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
@@ -351,6 +430,35 @@ describe("ralph-loop", () => {
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should detect completion promise via session messages API", async () => {
|
||||
// #given - active loop with assistant message containing completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. <promise>API_DONE</promise>" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop completed via API detection, no continuation
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
|
||||
// #then - messages API was called with correct session ID
|
||||
expect(messagesCalls.length).toBe(1)
|
||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should handle multiple iterations correctly", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
@@ -386,5 +494,162 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls[0].text).toContain("Create a calculator app")
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
|
||||
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError", message: "User aborted" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop state should be cleared immediately
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT set recovery mode on user abort", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Start a new loop
|
||||
hook.startLoop("session-123", "New task")
|
||||
|
||||
// #when - session goes idle immediately (should work, no recovery mode)
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - continuation should be injected (not blocked by recovery)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should only check LAST assistant message for completion", async () => {
|
||||
// #given - multiple assistant messages, only first has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should continue (last message has no completion promise)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should detect completion only in LAST assistant message", async () => {
|
||||
// #given - last assistant message has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should complete (last message has completion promise)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should check transcript BEFORE API to optimize performance", async () => {
|
||||
// #given - transcript has completion promise
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - should complete via transcript (API not called when transcript succeeds)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(hook.getState()).toBeNull()
|
||||
// API should NOT be called since transcript found completion
|
||||
expect(messagesCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
test("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async () => {
|
||||
// Simulate slow API (would hang without timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createRalphLoopHook(slowMock as any, {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
apiTimeout: 100, // 100ms timeout for test
|
||||
})
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - session goes idle (API will timeout)
|
||||
const startTime = Date.now()
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// #then - should complete within timeout + buffer (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
// #then - loop should continue (API timeout = no completion detected)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -18,6 +18,17 @@ interface SessionState {
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
interface OpenCodeSessionMessage {
|
||||
info?: {
|
||||
role?: string
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
|
||||
|
||||
Your previous attempt did not output the completion promise. Continue working on the task.
|
||||
@@ -42,6 +53,8 @@ export interface RalphLoopHook {
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 3000
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
@@ -50,6 +63,8 @@ export function createRalphLoopHook(
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
@@ -81,6 +96,43 @@ export function createRalphLoopHook(
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
async function detectCompletionInSessionMessages(
|
||||
sessionID: string,
|
||||
promise: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await Promise.race([
|
||||
ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
|
||||
),
|
||||
])
|
||||
|
||||
const messages = (response as { data?: unknown[] }).data ?? []
|
||||
if (!Array.isArray(messages)) return false
|
||||
|
||||
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
|
||||
(msg) => msg.info?.role === "assistant"
|
||||
)
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (!lastAssistant?.parts) return false
|
||||
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
const responseText = lastAssistant.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text ?? "")
|
||||
.join("\n")
|
||||
|
||||
return pattern.test(responseText)
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const startLoop = (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
@@ -148,17 +200,40 @@ export function createRalphLoopHook(
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
if (checkSessionExists) {
|
||||
try {
|
||||
const originalSessionExists = await checkSessionExists(state.session_id)
|
||||
if (!originalSessionExists) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||
orphanedSessionId: state.session_id,
|
||||
currentSessionId: sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||
sessionId: state.session_id,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
|
||||
|
||||
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
|
||||
const completionDetectedViaApi = completionDetectedViaTranscript
|
||||
? false
|
||||
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaTranscript || completionDetectedViaApi) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
@@ -256,6 +331,20 @@ export function createRalphLoopHook(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
|
||||
if (error?.name === "MessageAbortedError") {
|
||||
if (sessionID) {
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (state?.session_id === sessionID) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
|
||||
}
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionID) {
|
||||
const sessionState = getSessionState(sessionID)
|
||||
sessionState.isRecovering = true
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface RalphLoopState {
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
apiTimeout?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -14,10 +14,17 @@ export const PROJECT_MARKERS = [
|
||||
];
|
||||
|
||||
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
|
||||
[".github", "instructions"],
|
||||
[".cursor", "rules"],
|
||||
[".claude", "rules"],
|
||||
];
|
||||
|
||||
export const PROJECT_RULE_FILES: string[] = [
|
||||
".github/copilot-instructions.md",
|
||||
];
|
||||
|
||||
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
|
||||
|
||||
export const USER_RULE_DIR = ".claude/rules";
|
||||
|
||||
export const RULE_EXTENSIONS = [".md", ".mdc"];
|
||||
|
||||
381
src/hooks/rules-injector/finder.test.ts
Normal file
381
src/hooks/rules-injector/finder.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
|
||||
describe("findRuleFiles", () => {
|
||||
const TEST_DIR = join(tmpdir(), `rules-injector-test-${Date.now()}`);
|
||||
const homeDir = join(TEST_DIR, "home");
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe(".github/instructions/ discovery", () => {
|
||||
it("should discover .github/instructions/*.instructions.md files", () => {
|
||||
// #given .github/instructions/ with valid files
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
mkdirSync(instructionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(instructionsDir, "typescript.instructions.md"),
|
||||
"TS rules"
|
||||
);
|
||||
writeFileSync(
|
||||
join(instructionsDir, "python.instructions.md"),
|
||||
"PY rules"
|
||||
);
|
||||
|
||||
const srcDir = join(TEST_DIR, "src");
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
const currentFile = join(srcDir, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules for a file
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find both instruction files
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(
|
||||
paths.some((p) => p.includes("typescript.instructions.md"))
|
||||
).toBe(true);
|
||||
expect(paths.some((p) => p.includes("python.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore non-.instructions.md files in .github/instructions/", () => {
|
||||
// #given .github/instructions/ with invalid files
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
mkdirSync(instructionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(instructionsDir, "valid.instructions.md"),
|
||||
"valid"
|
||||
);
|
||||
writeFileSync(join(instructionsDir, "invalid.md"), "invalid");
|
||||
writeFileSync(join(instructionsDir, "readme.txt"), "readme");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should only find .instructions.md file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes("valid.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(paths.some((p) => p.endsWith("invalid.md"))).toBe(false);
|
||||
expect(paths.some((p) => p.includes("readme.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should discover nested .instructions.md files in subdirectories", () => {
|
||||
// #given nested .github/instructions/ structure
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
const frontendDir = join(instructionsDir, "frontend");
|
||||
mkdirSync(frontendDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(frontendDir, "react.instructions.md"),
|
||||
"React rules"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.tsx");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find nested instruction file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes("react.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".github/copilot-instructions.md (single file)", () => {
|
||||
it("should discover copilot-instructions.md at project root", () => {
|
||||
// #given .github/copilot-instructions.md at root
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Global instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find the single file rule
|
||||
const singleFile = candidates.find((c) =>
|
||||
c.path.includes("copilot-instructions.md")
|
||||
);
|
||||
expect(singleFile).toBeDefined();
|
||||
expect(singleFile?.isSingleFile).toBe(true);
|
||||
});
|
||||
|
||||
it("should mark single file rules with isSingleFile: true", () => {
|
||||
// #given copilot-instructions.md
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then isSingleFile should be true
|
||||
const copilotFile = candidates.find((c) => c.isSingleFile);
|
||||
expect(copilotFile).toBeDefined();
|
||||
expect(copilotFile?.path).toContain("copilot-instructions.md");
|
||||
});
|
||||
|
||||
it("should set distance to 0 for single file rules", () => {
|
||||
// #given copilot-instructions.md at project root
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const srcDir = join(TEST_DIR, "src", "deep", "nested");
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
const currentFile = join(srcDir, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules from deeply nested file
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then single file should have distance 0
|
||||
const copilotFile = candidates.find((c) => c.isSingleFile);
|
||||
expect(copilotFile?.distance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("should still discover .claude/rules/ files", () => {
|
||||
// #given .claude/rules/ directory
|
||||
const rulesDir = join(TEST_DIR, ".claude", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "typescript.md"), "TS rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find claude rules
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should still discover .cursor/rules/ files", () => {
|
||||
// #given .cursor/rules/ directory
|
||||
const rulesDir = join(TEST_DIR, ".cursor", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "python.md"), "PY rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "main.py");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find cursor rules
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should discover .mdc files in rule directories", () => {
|
||||
// #given .mdc file in .claude/rules/
|
||||
const rulesDir = join(TEST_DIR, ".claude", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "advanced.mdc"), "MDC rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find .mdc file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.endsWith("advanced.mdc"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed sources", () => {
|
||||
it("should discover rules from all sources", () => {
|
||||
// #given rules in multiple directories
|
||||
const claudeRules = join(TEST_DIR, ".claude", "rules");
|
||||
const cursorRules = join(TEST_DIR, ".cursor", "rules");
|
||||
const githubInstructions = join(TEST_DIR, ".github", "instructions");
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
|
||||
mkdirSync(claudeRules, { recursive: true });
|
||||
mkdirSync(cursorRules, { recursive: true });
|
||||
mkdirSync(githubInstructions, { recursive: true });
|
||||
|
||||
writeFileSync(join(claudeRules, "claude.md"), "claude");
|
||||
writeFileSync(join(cursorRules, "cursor.md"), "cursor");
|
||||
writeFileSync(
|
||||
join(githubInstructions, "copilot.instructions.md"),
|
||||
"copilot"
|
||||
);
|
||||
writeFileSync(join(githubDir, "copilot-instructions.md"), "global");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find all rules
|
||||
expect(candidates.length).toBeGreaterThanOrEqual(4);
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
|
||||
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
|
||||
expect(paths.some((p) => p.includes(".github/instructions/"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(paths.some((p) => p.includes("copilot-instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should not duplicate single file rules", () => {
|
||||
// #given copilot-instructions.md
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should only have one copilot-instructions.md entry
|
||||
const copilotFiles = candidates.filter((c) =>
|
||||
c.path.includes("copilot-instructions.md")
|
||||
);
|
||||
expect(copilotFiles.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user-level rules", () => {
|
||||
it("should discover user-level .claude/rules/ files", () => {
|
||||
// #given user-level rules
|
||||
const userRulesDir = join(homeDir, ".claude", "rules");
|
||||
mkdirSync(userRulesDir, { recursive: true });
|
||||
writeFileSync(join(userRulesDir, "global.md"), "Global user rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find user-level rules
|
||||
const userRule = candidates.find((c) => c.isGlobal);
|
||||
expect(userRule).toBeDefined();
|
||||
expect(userRule?.path).toContain("global.md");
|
||||
});
|
||||
|
||||
it("should mark user-level rules as isGlobal: true", () => {
|
||||
// #given user-level rules
|
||||
const userRulesDir = join(homeDir, ".claude", "rules");
|
||||
mkdirSync(userRulesDir, { recursive: true });
|
||||
writeFileSync(join(userRulesDir, "user.md"), "User rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then isGlobal should be true
|
||||
const userRule = candidates.find((c) => c.path.includes("user.md"));
|
||||
expect(userRule?.isGlobal).toBe(true);
|
||||
expect(userRule?.distance).toBe(9999);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProjectRoot", () => {
|
||||
const TEST_DIR = join(tmpdir(), `project-root-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should find project root with .git directory", () => {
|
||||
// #given directory with .git
|
||||
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
|
||||
const nestedFile = join(TEST_DIR, "src", "components", "Button.tsx");
|
||||
mkdirSync(join(TEST_DIR, "src", "components"), { recursive: true });
|
||||
writeFileSync(nestedFile, "code");
|
||||
|
||||
// #when finding project root from nested file
|
||||
const root = findProjectRoot(nestedFile);
|
||||
|
||||
// #then should return the directory with .git
|
||||
expect(root).toBe(TEST_DIR);
|
||||
});
|
||||
|
||||
it("should find project root with package.json", () => {
|
||||
// #given directory with package.json
|
||||
writeFileSync(join(TEST_DIR, "package.json"), "{}");
|
||||
const nestedFile = join(TEST_DIR, "lib", "index.js");
|
||||
mkdirSync(join(TEST_DIR, "lib"), { recursive: true });
|
||||
writeFileSync(nestedFile, "code");
|
||||
|
||||
// #when finding project root
|
||||
const root = findProjectRoot(nestedFile);
|
||||
|
||||
// #then should find the package.json directory
|
||||
expect(root).toBe(TEST_DIR);
|
||||
});
|
||||
|
||||
it("should return null when no project markers found", () => {
|
||||
// #given directory without any project markers
|
||||
const isolatedDir = join(TEST_DIR, "isolated");
|
||||
mkdirSync(isolatedDir, { recursive: true });
|
||||
const file = join(isolatedDir, "file.txt");
|
||||
writeFileSync(file, "content");
|
||||
|
||||
// #when finding project root
|
||||
const root = findProjectRoot(file);
|
||||
|
||||
// #then should return null
|
||||
expect(root).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,24 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import {
|
||||
GITHUB_INSTRUCTIONS_PATTERN,
|
||||
PROJECT_MARKERS,
|
||||
PROJECT_RULE_FILES,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
RULE_EXTENSIONS,
|
||||
USER_RULE_DIR,
|
||||
} from "./constants";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
/**
|
||||
* Candidate rule file with metadata for filtering and sorting
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
/** Absolute path to the rule file */
|
||||
path: string;
|
||||
/** Real path after symlink resolution (for duplicate detection) */
|
||||
realPath: string;
|
||||
/** Whether this is a global/user-level rule */
|
||||
isGlobal: boolean;
|
||||
/** Directory distance from current file (9999 for global rules) */
|
||||
distance: number;
|
||||
function isGitHubInstructionsDir(dir: string): boolean {
|
||||
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
|
||||
}
|
||||
|
||||
function isValidRuleFile(fileName: string, dir: string): boolean {
|
||||
if (isGitHubInstructionsDir(dir)) {
|
||||
return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
|
||||
}
|
||||
return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +76,7 @@ function findRuleFilesRecursive(dir: string, results: string[]): void {
|
||||
if (entry.isDirectory()) {
|
||||
findRuleFilesRecursive(fullPath, results);
|
||||
} else if (entry.isFile()) {
|
||||
const isRuleFile = RULE_EXTENSIONS.some((ext) =>
|
||||
entry.name.endsWith(ext),
|
||||
);
|
||||
if (isRuleFile) {
|
||||
if (isValidRuleFile(entry.name, dir)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
@@ -133,8 +130,10 @@ export function calculateDistance(
|
||||
return 9999;
|
||||
}
|
||||
|
||||
const ruleParts = ruleRel ? ruleRel.split("/") : [];
|
||||
const currentParts = currentRel ? currentRel.split("/") : [];
|
||||
// Split by both forward and back slashes for cross-platform compatibility
|
||||
// path.relative() returns OS-native separators (backslashes on Windows)
|
||||
const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : [];
|
||||
const currentParts = currentRel ? currentRel.split(/[/\\]/) : [];
|
||||
|
||||
// Find common prefix length
|
||||
let common = 0;
|
||||
@@ -207,6 +206,33 @@ export function findRuleFiles(
|
||||
distance++;
|
||||
}
|
||||
|
||||
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
|
||||
if (projectRoot) {
|
||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||
const filePath = join(projectRoot, ruleFile);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (stat.isFile()) {
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (!seenRealPaths.has(realPath)) {
|
||||
seenRealPaths.add(realPath);
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance: 0,
|
||||
isSingleFile: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip if file can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search user-level rule directory (~/.claude/rules)
|
||||
const userRuleDir = join(homeDir, USER_RULE_DIR);
|
||||
const userFiles: string[] = [];
|
||||
|
||||
@@ -100,8 +100,14 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
@@ -112,7 +118,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason: matchResult.reason ?? "matched",
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
226
src/hooks/rules-injector/parser.test.ts
Normal file
226
src/hooks/rules-injector/parser.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
|
||||
describe("parseRuleFrontmatter", () => {
|
||||
describe("applyTo field (GitHub Copilot format)", () => {
|
||||
it("should parse applyTo as single string", () => {
|
||||
// #given frontmatter with applyTo as single string
|
||||
const content = `---
|
||||
applyTo: "*.ts"
|
||||
---
|
||||
Rule content here`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should contain the pattern
|
||||
expect(result.metadata.globs).toBe("*.ts");
|
||||
expect(result.body).toBe("Rule content here");
|
||||
});
|
||||
|
||||
it("should parse applyTo as inline array", () => {
|
||||
// #given frontmatter with applyTo as inline array
|
||||
const content = `---
|
||||
applyTo: ["*.ts", "*.tsx"]
|
||||
---
|
||||
Rule content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "*.tsx"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo as multi-line array", () => {
|
||||
// #given frontmatter with applyTo as multi-line array
|
||||
const content = `---
|
||||
applyTo:
|
||||
- "*.ts"
|
||||
- "src/**/*.js"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "src/**/*.js"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo as comma-separated string", () => {
|
||||
// #given frontmatter with comma-separated applyTo
|
||||
const content = `---
|
||||
applyTo: "*.ts, *.js"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "*.js"]);
|
||||
});
|
||||
|
||||
it("should merge applyTo and globs when both present", () => {
|
||||
// #given frontmatter with both applyTo and globs
|
||||
const content = `---
|
||||
globs: "*.md"
|
||||
applyTo: "*.ts"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should merge both into globs array
|
||||
expect(result.metadata.globs).toEqual(["*.md", "*.ts"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo without quotes", () => {
|
||||
// #given frontmatter with unquoted applyTo
|
||||
const content = `---
|
||||
applyTo: **/*.py
|
||||
---
|
||||
Python rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("**/*.py");
|
||||
});
|
||||
|
||||
it("should parse applyTo with description", () => {
|
||||
// #given frontmatter with applyTo and description (GitHub Copilot style)
|
||||
const content = `---
|
||||
applyTo: "**/*.ts,**/*.tsx"
|
||||
description: "TypeScript coding standards"
|
||||
---
|
||||
# TypeScript Guidelines`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse both fields
|
||||
expect(result.metadata.globs).toEqual(["**/*.ts", "**/*.tsx"]);
|
||||
expect(result.metadata.description).toBe("TypeScript coding standards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing globs/paths parsing (backward compatibility)", () => {
|
||||
it("should still parse globs field correctly", () => {
|
||||
// #given existing globs format
|
||||
const content = `---
|
||||
globs: ["*.py", "**/*.ts"]
|
||||
---
|
||||
Python/TypeScript rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should work as before
|
||||
expect(result.metadata.globs).toEqual(["*.py", "**/*.ts"]);
|
||||
});
|
||||
|
||||
it("should still parse paths field as alias", () => {
|
||||
// #given paths field (Claude Code style)
|
||||
const content = `---
|
||||
paths: ["src/**"]
|
||||
---
|
||||
Source rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should map to globs
|
||||
expect(result.metadata.globs).toEqual(["src/**"]);
|
||||
});
|
||||
|
||||
it("should parse alwaysApply correctly", () => {
|
||||
// #given frontmatter with alwaysApply
|
||||
const content = `---
|
||||
alwaysApply: true
|
||||
---
|
||||
Always apply this rule`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should recognize alwaysApply
|
||||
expect(result.metadata.alwaysApply).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no frontmatter", () => {
|
||||
it("should return empty metadata and full body for plain markdown", () => {
|
||||
// #given markdown without frontmatter
|
||||
const content = `# Instructions
|
||||
This is a plain rule file without frontmatter.`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should have empty metadata
|
||||
expect(result.metadata).toEqual({});
|
||||
expect(result.body).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
// #given empty content
|
||||
const content = "";
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should return empty metadata and body
|
||||
expect(result.metadata).toEqual({});
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle frontmatter with only applyTo", () => {
|
||||
// #given minimal GitHub Copilot format
|
||||
const content = `---
|
||||
applyTo: "**"
|
||||
---
|
||||
Apply to all files`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("**");
|
||||
expect(result.body).toBe("Apply to all files");
|
||||
});
|
||||
|
||||
it("should handle mixed array formats", () => {
|
||||
// #given globs as multi-line and applyTo as inline
|
||||
const content = `---
|
||||
globs:
|
||||
- "*.md"
|
||||
applyTo: ["*.ts", "*.js"]
|
||||
---
|
||||
Mixed format`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should merge both
|
||||
expect(result.metadata.globs).toEqual(["*.md", "*.ts", "*.js"]);
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
// #given content with CRLF
|
||||
const content = "---\r\napplyTo: \"*.ts\"\r\n---\r\nWindows content";
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("*.ts");
|
||||
expect(result.body).toBe("Windows content");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ function parseYamlContent(yamlContent: string): RuleMetadata {
|
||||
metadata.description = parseStringValue(rawValue);
|
||||
} else if (key === "alwaysApply") {
|
||||
metadata.alwaysApply = rawValue === "true";
|
||||
} else if (key === "globs" || key === "paths") {
|
||||
} else if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
|
||||
// Merge paths into globs (Claude Code compatibility)
|
||||
if (key === "paths") {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Rule file metadata (Claude Code style frontmatter)
|
||||
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
|
||||
* @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
|
||||
*/
|
||||
export interface RuleMetadata {
|
||||
description?: string;
|
||||
@@ -30,6 +32,18 @@ export interface RuleInfo {
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule file candidate with discovery context
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
path: string;
|
||||
realPath: string;
|
||||
isGlobal: boolean;
|
||||
distance: number;
|
||||
/** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */
|
||||
isSingleFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session storage for injected rules tracking
|
||||
*/
|
||||
|
||||
203
src/hooks/session-recovery/index.test.ts
Normal file
203
src/hooks/session-recovery/index.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { detectErrorType } from "./index"
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
describe("thinking_block_order errors", () => {
|
||||
it("should detect 'first block' error pattern", () => {
|
||||
// #given an error about thinking being the first block
|
||||
const error = {
|
||||
message: "messages.0: thinking block must not be the first block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'must start with' error pattern", () => {
|
||||
// #given an error about message must start with something
|
||||
const error = {
|
||||
message: "messages.5: thinking must start with text or tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'preceeding' error pattern", () => {
|
||||
// #given an error about preceeding block
|
||||
const error = {
|
||||
message: "messages.10: thinking requires preceeding text block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'expected/found' error pattern", () => {
|
||||
// #given an error about expected vs found
|
||||
const error = {
|
||||
message: "messages.3: thinking block expected text but found tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block cannot be thinking' error pattern", () => {
|
||||
// #given an error about final block cannot be thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.125: The final block in an assistant message cannot be thinking.",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block' variant error pattern", () => {
|
||||
// #given an error mentioning final block with thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.17: thinking in the final block is not allowed in assistant messages",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'cannot be thinking' error pattern", () => {
|
||||
// #given an error using 'cannot be thinking' phrasing
|
||||
const error = {
|
||||
message:
|
||||
"messages.219: The last block in an assistant message cannot be thinking content",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool_result_missing errors", () => {
|
||||
it("should detect tool_use/tool_result mismatch", () => {
|
||||
// #given an error about tool_use without tool_result
|
||||
const error = {
|
||||
message: "tool_use block requires corresponding tool_result",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return tool_result_missing
|
||||
expect(result).toBe("tool_result_missing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking_disabled_violation errors", () => {
|
||||
it("should detect thinking disabled violation", () => {
|
||||
// #given an error about thinking being disabled
|
||||
const error = {
|
||||
message:
|
||||
"thinking is disabled for this model and cannot contain thinking blocks",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_disabled_violation
|
||||
expect(result).toBe("thinking_disabled_violation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrecognized errors", () => {
|
||||
it("should return null for unrecognized error patterns", () => {
|
||||
// #given an unrelated error
|
||||
const error = {
|
||||
message: "Rate limit exceeded",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty error", () => {
|
||||
// #given an empty error
|
||||
const error = {}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for null error", () => {
|
||||
// #given a null error
|
||||
const error = null
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested error objects", () => {
|
||||
it("should detect error in data.error.message path", () => {
|
||||
// #given an error with nested structure
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
message:
|
||||
"messages.163: The final block in an assistant message cannot be thinking.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect error in error.message path", () => {
|
||||
// #given an error with error.message structure
|
||||
const error = {
|
||||
error: {
|
||||
message: "messages.169: final block cannot be thinking",
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -122,7 +122,7 @@ function extractMessageIndex(error: unknown): number | null {
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
@@ -134,6 +134,8 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
|
||||
@@ -322,4 +322,140 @@ describe("think-mode switcher", () => {
|
||||
expect(config.maxTokens).toBe(64000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Custom provider prefixes support", () => {
|
||||
describe("getHighVariant with prefixes", () => {
|
||||
it("should preserve vertex_ai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with vertex_ai/ prefix
|
||||
const variant = getHighVariant("vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
})
|
||||
|
||||
it("should preserve openai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with openai/ prefix
|
||||
const variant = getHighVariant("openai/gpt-5-2")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("openai/gpt-5-2-high")
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// #then should normalize dots and preserve prefix
|
||||
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
})
|
||||
|
||||
it("should handle multiple different prefixes", () => {
|
||||
// #given various custom prefixes
|
||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
||||
expect(getHighVariant("bedrock/claude-sonnet-4-5")).toBe("bedrock/claude-sonnet-4-5-high")
|
||||
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
|
||||
})
|
||||
|
||||
it("should return null for prefixed models without high variant mapping", () => {
|
||||
// #given prefixed model IDs without high variant mapping
|
||||
expect(getHighVariant("vertex_ai/unknown-model")).toBeNull()
|
||||
expect(getHighVariant("custom/llama-3-70b")).toBeNull()
|
||||
})
|
||||
|
||||
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("openai/gpt-5-2-high")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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("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("openai/gpt-5-2")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle prefixed models with dots", () => {
|
||||
// #given prefixed model IDs with dots
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getThinkingConfig with prefixes", () => {
|
||||
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
|
||||
// #given custom provider with prefixed Claude model
|
||||
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return null (custom provider not in THINKING_CONFIGS)
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// #then should return thinking config (base model is capable)
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
// #then should return null
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world custom provider scenario", () => {
|
||||
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
|
||||
// #given a custom LLM proxy provider using vertex_ai/ prefix
|
||||
const providerID = "dia-llm"
|
||||
const modelID = "vertex_ai/claude-sonnet-4-5"
|
||||
|
||||
// #when getting high variant
|
||||
const highVariant = getHighVariant(modelID)
|
||||
|
||||
// #then should preserve the prefix
|
||||
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
|
||||
// #and when checking if already high
|
||||
expect(isAlreadyHighVariant(modelID)).toBe(false)
|
||||
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
|
||||
|
||||
// #and when getting thinking config for custom provider
|
||||
const config = getThinkingConfig(providerID, modelID)
|
||||
|
||||
// #then should return null (custom provider, not anthropic)
|
||||
// This prevents applying incompatible thinking configs to custom providers
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
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 high = getHighVariant(original)
|
||||
|
||||
// #then the high variant should be valid
|
||||
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
|
||||
// #and should be recognized as already high
|
||||
expect(isAlreadyHighVariant(high!)).toBe(true)
|
||||
|
||||
// #and switching again should return null (already high)
|
||||
expect(getHighVariant(high!)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
* inconsistencies defensively while maintaining backwards compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts provider-specific prefix from model ID (if present).
|
||||
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
|
||||
*
|
||||
* @example
|
||||
* extractModelPrefix("vertex_ai/claude-sonnet-4-5") // { prefix: "vertex_ai/", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("claude-sonnet-4-5") // { prefix: "", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("openai/gpt-5.2") // { prefix: "openai/", base: "gpt-5.2" }
|
||||
*/
|
||||
function extractModelPrefix(modelID: string): { prefix: string; base: string } {
|
||||
const slashIndex = modelID.indexOf("/")
|
||||
if (slashIndex === -1) {
|
||||
return { prefix: "", base: modelID }
|
||||
}
|
||||
return {
|
||||
prefix: modelID.slice(0, slashIndex + 1),
|
||||
base: modelID.slice(slashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -25,6 +45,7 @@
|
||||
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
|
||||
* 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"
|
||||
*/
|
||||
function normalizeModelID(modelID: string): string {
|
||||
// Replace dots with hyphens when followed by a digit
|
||||
@@ -142,16 +163,27 @@ const THINKING_CAPABLE_MODELS = {
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { prefix, base } = extractModelPrefix(normalized)
|
||||
|
||||
if (ALREADY_HIGH.has(normalized)) {
|
||||
// Check if already high variant (with or without prefix)
|
||||
if (ALREADY_HIGH.has(base) || base.endsWith("-high")) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[normalized] ?? null
|
||||
|
||||
// Look up high variant for base model
|
||||
const highBase = HIGH_VARIANT_MAP[base]
|
||||
if (!highBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Preserve prefix in the high variant
|
||||
return prefix + highBase
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return ALREADY_HIGH.has(normalized) || normalized.endsWith("-high")
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||
}
|
||||
|
||||
type ThinkingProvider = keyof typeof THINKING_CONFIGS
|
||||
@@ -165,6 +197,7 @@ export function getThinkingConfig(
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
|
||||
if (isAlreadyHighVariant(normalized)) {
|
||||
return null
|
||||
@@ -179,9 +212,10 @@ export function getThinkingConfig(
|
||||
const config = THINKING_CONFIGS[resolvedProvider]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
|
||||
|
||||
const modelLower = normalized.toLowerCase()
|
||||
// Check capability using base model name (without prefix)
|
||||
const baseLower = base.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
baseLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
return isCapable ? config : null
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
interface MockMessage {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
error?: { name: string; data?: { message: string } }
|
||||
}
|
||||
}
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
@@ -16,10 +26,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]}),
|
||||
messages: async () => ({ data: mockMessages }),
|
||||
prompt: async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
@@ -41,8 +53,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
@@ -50,6 +62,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
mockMessages = []
|
||||
setMainSession(undefined)
|
||||
subagentSessions.clear()
|
||||
})
|
||||
@@ -164,58 +177,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
|
||||
test("should skip injection after recent error", async () => {
|
||||
// #given - session that just had an error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (error cooldown)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear error state on user message and allow injection", async () => {
|
||||
// #given - session with error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears error immediately)
|
||||
await hook.handler({
|
||||
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (error was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on user message", async () => {
|
||||
test("should cancel countdown on user message after grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-cancel"
|
||||
setMainSession(sessionID)
|
||||
@@ -227,19 +191,46 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
// #when - wait past grace period (500ms), then user sends message
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection
|
||||
// #then - wait past countdown time and verify no injection (countdown was cancelled)
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should ignore user message within grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-grace"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user message arrives within grace period (immediately)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - countdown should continue (message was ignored)
|
||||
// wait past 2s countdown and verify injection happens
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-assistant"
|
||||
@@ -255,9 +246,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -387,7 +378,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -396,9 +387,146 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
}, { timeout: 15000 })
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-noabort-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - non-abort error occurs (e.g., network error, API error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (non-abort errors don't block)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// API-BASED ABORT DETECTION TESTS
|
||||
// These tests verify that abort is detected by checking
|
||||
// the last assistant message's error field via session.messages API
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection when last assistant message has MessageAbortedError", async () => {
|
||||
// #given - session where last assistant message was aborted
|
||||
const sessionID = "main-api-abort"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "The operation was aborted" } } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (last message was aborted)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject when last assistant message has no error", async () => {
|
||||
// #given - session where last assistant message completed normally
|
||||
const sessionID = "main-api-no-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (no abort)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject when last message is from user (not assistant)", async () => {
|
||||
// #given - session where last message is from user
|
||||
const sessionID = "main-api-user-last"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "user" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (last message is user, not aborted assistant)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should skip when last assistant message has any abort-like error", async () => {
|
||||
// #given - session where last assistant message has AbortError (DOMException style)
|
||||
const sessionID = "main-api-abort-dom"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "AbortError" } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort error detected)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
@@ -29,10 +29,10 @@ interface Todo {
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastErrorAt?: number
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
@@ -45,7 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900
|
||||
const ERROR_COOLDOWN_MS = 3_000
|
||||
const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
@@ -61,31 +61,30 @@ function getMessageDir(sessionID: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
||||
}
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: { name?: string; data?: unknown }
|
||||
}
|
||||
|
||||
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
|
||||
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const errorName = lastAssistant.info?.error?.name
|
||||
|
||||
if (!errorName) return false
|
||||
|
||||
return errorName === "MessageAbortedError" || errorName === "AbortError"
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
@@ -105,7 +104,7 @@ export function createTodoContinuationEnforcer(
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
@@ -114,6 +113,7 @@ export function createTodoContinuationEnforcer(
|
||||
clearInterval(state.countdownInterval)
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
state.countdownStartedAt = undefined
|
||||
}
|
||||
|
||||
function cleanup(sessionID: string): void {
|
||||
@@ -149,16 +149,13 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
|
||||
if (state?.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
@@ -187,9 +184,9 @@ export function createTodoContinuationEnforcer(
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
|
||||
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
@@ -203,18 +200,23 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||
@@ -227,6 +229,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(secondsRemaining, incompleteCount)
|
||||
state.countdownStartedAt = Date.now()
|
||||
|
||||
state.countdownInterval = setInterval(() => {
|
||||
secondsRemaining--
|
||||
@@ -250,11 +253,8 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
state.lastErrorAt = Date.now()
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ export function createTodoContinuationEnforcer(
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isMainSession = sessionID === mainSessionID
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
|
||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
||||
return
|
||||
@@ -280,11 +280,6 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -294,6 +289,21 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
@@ -327,11 +337,14 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastErrorAt = undefined
|
||||
if (state?.countdownStartedAt) {
|
||||
const elapsed = Date.now() - state.countdownStartedAt
|
||||
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
|
||||
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
|
||||
return
|
||||
}
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
|
||||
168
src/hooks/tool-output-truncator.test.ts
Normal file
168
src/hooks/tool-output-truncator.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import { createToolOutputTruncatorHook } from "./tool-output-truncator"
|
||||
import * as dynamicTruncator from "../shared/dynamic-truncator"
|
||||
|
||||
describe("createToolOutputTruncatorHook", () => {
|
||||
let hook: ReturnType<typeof createToolOutputTruncatorHook>
|
||||
let truncateSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
truncateSpy = spyOn(dynamicTruncator, "createDynamicTruncator").mockReturnValue({
|
||||
truncate: mock(async (_sessionID: string, output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: output,
|
||||
truncated: false,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
})),
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Result",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given webfetch tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use aggressive truncation limit (10k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("webfetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when using WebFetch variant", () => {
|
||||
it("#then should also use aggressive truncation limit", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("WebFetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given grep tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use default truncation limit (50k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("grep")
|
||||
const output = createOutput("grep output")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"grep output",
|
||||
{ targetMaxTokens: 50_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-truncatable tool", () => {
|
||||
describe("#when tool is not in TRUNCATABLE_TOOLS list", () => {
|
||||
it("#then should not call truncator", async () => {
|
||||
const truncateMock = mock(async () => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given truncate_all_tool_outputs enabled", () => {
|
||||
describe("#when any tool output is processed", () => {
|
||||
it("#then should truncate non-listed tools too", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never, {
|
||||
experimental: { truncate_all_tool_outputs: true },
|
||||
})
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../config/schema"
|
||||
import { createDynamicTruncator } from "../shared/dynamic-truncator"
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 50_000 // ~200k chars
|
||||
const WEBFETCH_MAX_TOKENS = 10_000 // ~40k chars - web pages need aggressive truncation
|
||||
|
||||
const TRUNCATABLE_TOOLS = [
|
||||
"grep",
|
||||
"Grep",
|
||||
@@ -21,6 +24,11 @@ const TRUNCATABLE_TOOLS = [
|
||||
"WebFetch",
|
||||
]
|
||||
|
||||
const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
|
||||
webfetch: WEBFETCH_MAX_TOKENS,
|
||||
WebFetch: WEBFETCH_MAX_TOKENS,
|
||||
}
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
@@ -36,7 +44,12 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
|
||||
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
|
||||
|
||||
try {
|
||||
const { result, truncated } = await truncator.truncate(input.sessionID, output.output)
|
||||
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
input.sessionID,
|
||||
output.output,
|
||||
{ targetMaxTokens }
|
||||
)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
|
||||
522
src/index.ts
522
src/index.ts
@@ -1,5 +1,4 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
@@ -26,166 +25,55 @@ import {
|
||||
createThinkingBlockValidatorHook,
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
createEditErrorRecoveryHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./features/context-injector";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "./features/claude-code-command-loader";
|
||||
import { loadBuiltinCommands } from "./features/builtin-commands";
|
||||
import {
|
||||
loadUserSkills,
|
||||
loadProjectSkills,
|
||||
loadOpencodeGlobalSkills,
|
||||
loadOpencodeProjectSkills,
|
||||
discoverUserClaudeSkills,
|
||||
discoverProjectClaudeSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
discoverOpencodeProjectSkills,
|
||||
discoverUserClaudeSkillsAsync,
|
||||
discoverProjectClaudeSkillsAsync,
|
||||
discoverOpencodeGlobalSkillsAsync,
|
||||
discoverOpencodeProjectSkillsAsync,
|
||||
mergeSkills,
|
||||
} from "./features/opencode-skill-loader";
|
||||
import { createBuiltinSkills } from "./features/builtin-skills";
|
||||
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "./features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
|
||||
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
|
||||
import {
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, createSkillMcpTool, interactive_bash, getTmuxPath } from "./tools";
|
||||
import {
|
||||
builtinTools,
|
||||
createCallOmoAgent,
|
||||
createBackgroundTools,
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
sessionExists,
|
||||
interactive_bash,
|
||||
startTmuxCheck,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile, migrateConfigFile } from "./shared";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||
|
||||
migrateConfigFile(configPath, rawConfig);
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
addConfigLoadError({ path: configPath, error: errorMsg });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeConfigs(
|
||||
base: OhMyOpenCodeConfig,
|
||||
override: OhMyOpenCodeConfig
|
||||
): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
...(override.disabled_agents ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([
|
||||
...(base.disabled_mcps ?? []),
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_hooks: [
|
||||
...new Set([
|
||||
...(base.disabled_hooks ?? []),
|
||||
...(override.disabled_hooks ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_commands: [
|
||||
...new Set([
|
||||
...(base.disabled_commands ?? []),
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_skills: [
|
||||
...new Set([
|
||||
...(base.disabled_skills ?? []),
|
||||
...(override.disabled_skills ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
log("Final merged config", {
|
||||
agents: config.agents,
|
||||
disabled_agents: config.disabled_agents,
|
||||
disabled_mcps: config.disabled_mcps,
|
||||
disabled_hooks: config.disabled_hooks,
|
||||
claude_code: config.claude_code,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
import { type HookName } from "./config";
|
||||
import { log } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
// Start background tmux check immediately
|
||||
startTmuxCheck();
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
||||
|
||||
const modelContextLimitsCache = new Map<string, number>();
|
||||
let anthropicContext1MEnabled = false;
|
||||
|
||||
const getModelLimit = (providerID: string, modelID: string): number | undefined => {
|
||||
const key = `${providerID}/${modelID}`;
|
||||
const cached = modelContextLimitsCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
if (providerID === "anthropic" && anthropicContext1MEnabled && modelID.includes("sonnet")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const modelCacheState = createModelCacheState();
|
||||
|
||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||
? createContextWindowMonitorHook(ctx)
|
||||
@@ -201,7 +89,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createCommentCheckerHooks(pluginConfig.comment_checker)
|
||||
: null;
|
||||
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
|
||||
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
|
||||
? createToolOutputTruncatorHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
})
|
||||
: null;
|
||||
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
|
||||
? createDirectoryAgentsInjectorHook(ctx)
|
||||
@@ -212,13 +102,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
|
||||
? createEmptyTaskResponseDetectorHook(ctx)
|
||||
: null;
|
||||
const thinkMode = isHookEnabled("think-mode")
|
||||
? createThinkModeHook()
|
||||
: null;
|
||||
const thinkMode = isHookEnabled("think-mode") ? createThinkModeHook() : null;
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
|
||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||
});
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled(
|
||||
"anthropic-context-window-limit-recovery"
|
||||
)
|
||||
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
|
||||
@@ -231,7 +121,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createPreemptiveCompactionHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
onBeforeSummarize: compactionContextInjector,
|
||||
getModelLimit,
|
||||
getModelLimit: (providerID, modelID) =>
|
||||
getModelLimit(modelCacheState, providerID, modelID),
|
||||
})
|
||||
: null;
|
||||
const rulesInjector = isHookEnabled("rules-injector")
|
||||
@@ -247,6 +138,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook(ctx)
|
||||
: null;
|
||||
const contextInjector = createContextInjectorHook(contextCollector);
|
||||
const contextInjectorMessagesTransform =
|
||||
createContextInjectorMessagesTransformHook(contextCollector);
|
||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||
? createAgentUsageReminderHook(ctx)
|
||||
: null;
|
||||
@@ -264,13 +158,20 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const ralphLoop = isHookEnabled("ralph-loop")
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
? createRalphLoopHook(ctx, {
|
||||
config: pluginConfig.ralph_loop,
|
||||
checkSessionExists: async (sessionId) => sessionExists(sessionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
@@ -279,7 +180,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
if (sessionRecovery && todoContinuationEnforcer) {
|
||||
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(
|
||||
todoContinuationEnforcer.markRecoveryComplete
|
||||
);
|
||||
}
|
||||
|
||||
const backgroundNotificationHook = isHookEnabled("background-notification")
|
||||
@@ -290,17 +193,30 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
const lookAt = createLookAt(ctx);
|
||||
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const builtinSkills = createBuiltinSkills().filter(
|
||||
(skill) => !disabledSkills.has(skill.name as any)
|
||||
);
|
||||
const systemMcpNames = getSystemMcpServerNames();
|
||||
const builtinSkills = createBuiltinSkills().filter((skill) => {
|
||||
if (disabledSkills.has(skill.name as never)) return false;
|
||||
if (skill.mcpConfig) {
|
||||
for (const mcpName of Object.keys(skill.mcpConfig)) {
|
||||
if (systemMcpNames.has(mcpName)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
|
||||
includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkillsAsync(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkillsAsync(),
|
||||
]);
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills(),
|
||||
userSkills,
|
||||
globalSkills,
|
||||
projectSkills,
|
||||
opencodeProjectSkills
|
||||
);
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
@@ -319,7 +235,11 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
const tmuxAvailable = await getTmuxPath();
|
||||
const configHandler = createConfigHandler({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
});
|
||||
|
||||
return {
|
||||
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
|
||||
@@ -331,43 +251,64 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
...(tmuxAvailable ? { interactive_bash } : {}),
|
||||
interactive_bash, // Always included, handles missing tmux gracefully via getCachedTmuxPath() ?? "tmux"
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await contextInjector["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
|
||||
if (ralphLoop) {
|
||||
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
|
||||
const promptText = parts
|
||||
?.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
.trim() || "";
|
||||
const parts = (
|
||||
output as { parts?: Array<{ type: string; text?: string }> }
|
||||
).parts;
|
||||
const promptText =
|
||||
parts
|
||||
?.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
.trim() || "";
|
||||
|
||||
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
|
||||
const isRalphLoopTemplate =
|
||||
promptText.includes("You are starting a Ralph Loop") &&
|
||||
promptText.includes("<user-task>");
|
||||
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
|
||||
const isCancelRalphTemplate = promptText.includes(
|
||||
"Cancel the currently active Ralph Loop"
|
||||
);
|
||||
|
||||
if (isRalphLoopTemplate) {
|
||||
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i);
|
||||
const taskMatch = promptText.match(
|
||||
/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i
|
||||
);
|
||||
const rawTask = taskMatch?.[1]?.trim() || "";
|
||||
|
||||
|
||||
const quotedMatch = rawTask.match(/^["'](.+?)["']/);
|
||||
const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
||||
const prompt =
|
||||
quotedMatch?.[1] ||
|
||||
rawTask.split(/\s+--/)[0]?.trim() ||
|
||||
"Complete the task as instructed";
|
||||
|
||||
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
|
||||
const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
||||
const promiseMatch = rawTask.match(
|
||||
/--completion-promise=["']?([^"'\s]+)["']?/i
|
||||
);
|
||||
|
||||
log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
|
||||
log("[ralph-loop] Starting loop from chat.message", {
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
});
|
||||
ralphLoop.startLoop(input.sessionID, prompt, {
|
||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
||||
maxIterations: maxIterMatch
|
||||
? parseInt(maxIterMatch[1], 10)
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
} else if (isCancelRalphTemplate) {
|
||||
log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
|
||||
log("[ralph-loop] Cancelling loop from chat.message", {
|
||||
sessionID: input.sessionID,
|
||||
});
|
||||
ralphLoop.cancelLoop(input.sessionID);
|
||||
}
|
||||
}
|
||||
@@ -378,208 +319,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
await contextInjectorMessagesTransform?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
await thinkingBlockValidator?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
]?.(input, output as any);
|
||||
await emptyMessageSanitizer?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
]?.(input, output as any);
|
||||
},
|
||||
|
||||
config: async (config) => {
|
||||
type ProviderConfig = {
|
||||
options?: { headers?: Record<string, string> }
|
||||
models?: Record<string, { limit?: { context?: number } }>
|
||||
}
|
||||
const providers = config.provider as Record<string, ProviderConfig> | undefined;
|
||||
|
||||
const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"];
|
||||
anthropicContext1MEnabled = anthropicBeta?.includes("context-1m") ?? false;
|
||||
|
||||
if (providers) {
|
||||
for (const [providerID, providerConfig] of Object.entries(providers)) {
|
||||
const models = providerConfig?.models;
|
||||
if (models) {
|
||||
for (const [modelID, modelConfig] of Object.entries(models)) {
|
||||
const contextLimit = modelConfig?.limit?.context;
|
||||
if (contextLimit) {
|
||||
modelContextLimitsCache.set(`${providerID}/${modelID}`, contextLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
||||
? await loadAllPluginComponents({
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
|
||||
|
||||
if (pluginComponents.plugins.length > 0) {
|
||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginComponents.errors.length > 0) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
config.model,
|
||||
);
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
};
|
||||
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
|
||||
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
|
||||
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: config.agent?.plan?.color ?? "#6495ED",
|
||||
};
|
||||
|
||||
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
|
||||
const filteredConfigAgents = config.agent ?
|
||||
Object.fromEntries(
|
||||
Object.entries(config.agent).filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
) : {};
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
|
||||
// Demote build/plan to subagent mode when replaced
|
||||
build: { ...config.agent?.build, mode: "subagent" },
|
||||
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...config.agent,
|
||||
};
|
||||
}
|
||||
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
"grep_app_*": false, // Disable grep_app tools globally to reduce token usage (only librarian needs them)
|
||||
};
|
||||
|
||||
if (config.agent.explore) {
|
||||
config.agent.explore.tools = {
|
||||
...config.agent.explore.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (config.agent.librarian) {
|
||||
config.agent.librarian.tools = {
|
||||
...config.agent.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
"grep_app_*": true,
|
||||
};
|
||||
}
|
||||
if (config.agent["multimodal-looker"]) {
|
||||
config.agent["multimodal-looker"].tools = {
|
||||
...config.agent["multimodal-looker"].tools,
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
};
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
...config.permission,
|
||||
webfetch: "allow",
|
||||
external_directory: "allow",
|
||||
}
|
||||
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkills() : {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkills() : {};
|
||||
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
||||
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...opencodeGlobalSkills,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeProjectSkills,
|
||||
...pluginComponents.commands,
|
||||
...pluginComponents.skills,
|
||||
};
|
||||
},
|
||||
config: configHandler,
|
||||
|
||||
event: async (input) => {
|
||||
await autoUpdateChecker?.event(input);
|
||||
@@ -658,7 +409,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
if (input.tool === "task") {
|
||||
const args = output.args as Record<string, unknown>;
|
||||
const subagentType = args.subagent_type as string;
|
||||
const isExploreOrLibrarian = ["explore", "librarian"].includes(subagentType);
|
||||
const isExploreOrLibrarian = ["explore", "librarian"].includes(
|
||||
subagentType
|
||||
);
|
||||
|
||||
args.tools = {
|
||||
...(args.tools as Record<string, boolean> | undefined),
|
||||
@@ -673,15 +426,23 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const sessionID = input.sessionID || getMainSessionID();
|
||||
|
||||
if (command === "ralph-loop" && sessionID) {
|
||||
const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
|
||||
const rawArgs =
|
||||
args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
|
||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
|
||||
const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
||||
const prompt =
|
||||
taskMatch?.[1] ||
|
||||
rawArgs.split(/\s+--/)[0]?.trim() ||
|
||||
"Complete the task as instructed";
|
||||
|
||||
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
|
||||
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
||||
const promiseMatch = rawArgs.match(
|
||||
/--completion-promise=["']?([^"'\s]+)["']?/i
|
||||
);
|
||||
|
||||
ralphLoop.startLoop(sessionID, prompt, {
|
||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
||||
maxIterations: maxIterMatch
|
||||
? parseInt(maxIterMatch[1], 10)
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
} else if (command === "cancel-ralph" && sessionID) {
|
||||
@@ -701,6 +462,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
134
src/plugin-config.ts
Normal file
134
src/plugin-config.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import {
|
||||
log,
|
||||
deepMerge,
|
||||
getUserConfigDir,
|
||||
addConfigLoadError,
|
||||
parseJsonc,
|
||||
detectConfigFile,
|
||||
migrateConfigFile,
|
||||
} from "./shared";
|
||||
|
||||
export function loadConfigFromPath(
|
||||
configPath: string,
|
||||
ctx: unknown
|
||||
): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||
|
||||
migrateConfigFile(configPath, rawConfig);
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ");
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
addConfigLoadError({
|
||||
path: configPath,
|
||||
error: `Validation error: ${errorMsg}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
addConfigLoadError({ path: configPath, error: errorMsg });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mergeConfigs(
|
||||
base: OhMyOpenCodeConfig,
|
||||
override: OhMyOpenCodeConfig
|
||||
): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
...(override.disabled_agents ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([
|
||||
...(base.disabled_mcps ?? []),
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_hooks: [
|
||||
...new Set([
|
||||
...(base.disabled_hooks ?? []),
|
||||
...(override.disabled_hooks ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_commands: [
|
||||
...new Set([
|
||||
...(base.disabled_commands ?? []),
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_skills: [
|
||||
...new Set([
|
||||
...(base.disabled_skills ?? []),
|
||||
...(override.disabled_skills ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPluginConfig(
|
||||
directory: string,
|
||||
ctx: unknown
|
||||
): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||
const userBasePath = path.join(
|
||||
getUserConfigDir(),
|
||||
"opencode",
|
||||
"oh-my-opencode"
|
||||
);
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
const userConfigPath =
|
||||
userDetected.format !== "none"
|
||||
? userDetected.path
|
||||
: userBasePath + ".json";
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
const projectConfigPath =
|
||||
projectDetected.format !== "none"
|
||||
? projectDetected.path
|
||||
: projectBasePath + ".json";
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig =
|
||||
loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
log("Final merged config", {
|
||||
agents: config.agents,
|
||||
disabled_agents: config.disabled_agents,
|
||||
disabled_mcps: config.disabled_mcps,
|
||||
disabled_hooks: config.disabled_hooks,
|
||||
claude_code: config.claude_code,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
319
src/plugin-handlers/config-handler.ts
Normal file
319
src/plugin-handlers/config-handler.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { createBuiltinAgents } from "../agents";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "../features/claude-code-command-loader";
|
||||
import { loadBuiltinCommands } from "../features/builtin-commands";
|
||||
import {
|
||||
loadUserSkills,
|
||||
loadProjectSkills,
|
||||
loadOpencodeGlobalSkills,
|
||||
loadOpencodeProjectSkills,
|
||||
} from "../features/opencode-skill-loader";
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "../features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||
import { createBuiltinMcps } from "../mcp";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { log } from "../shared";
|
||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
|
||||
export interface ConfigHandlerDeps {
|
||||
ctx: { directory: string };
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
modelCacheState: ModelCacheState;
|
||||
}
|
||||
|
||||
export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const { ctx, pluginConfig, modelCacheState } = deps;
|
||||
|
||||
return async (config: Record<string, unknown>) => {
|
||||
type ProviderConfig = {
|
||||
options?: { headers?: Record<string, string> };
|
||||
models?: Record<string, { limit?: { context?: number } }>;
|
||||
};
|
||||
const providers = config.provider as
|
||||
| Record<string, ProviderConfig>
|
||||
| undefined;
|
||||
|
||||
const anthropicBeta =
|
||||
providers?.anthropic?.options?.headers?.["anthropic-beta"];
|
||||
modelCacheState.anthropicContext1MEnabled =
|
||||
anthropicBeta?.includes("context-1m") ?? false;
|
||||
|
||||
if (providers) {
|
||||
for (const [providerID, providerConfig] of Object.entries(providers)) {
|
||||
const models = providerConfig?.models;
|
||||
if (models) {
|
||||
for (const [modelID, modelConfig] of Object.entries(models)) {
|
||||
const contextLimit = modelConfig?.limit?.context;
|
||||
if (contextLimit) {
|
||||
modelCacheState.modelContextLimitsCache.set(
|
||||
`${providerID}/${modelID}`,
|
||||
contextLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
||||
? await loadAllPluginComponents({
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
: {
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (pluginComponents.plugins.length > 0) {
|
||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||
plugins: pluginComponents.plugins.map((p) => `${p.name}@${p.version}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginComponents.errors.length > 0) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
const rawUserAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadUserAgents()
|
||||
: {};
|
||||
const rawProjectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
|
||||
const userAgents = Object.fromEntries(
|
||||
Object.entries(rawUserAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
const projectAgents = Object.fromEntries(
|
||||
Object.entries(rawProjectAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
const pluginAgents = Object.fromEntries(
|
||||
Object.entries(rawPluginAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
const plannerEnabled =
|
||||
pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
type AgentConfig = Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
> & {
|
||||
build?: Record<string, unknown>;
|
||||
plan?: Record<string, unknown>;
|
||||
explore?: { tools?: Record<string, unknown> };
|
||||
librarian?: { tools?: Record<string, unknown> };
|
||||
"multimodal-looker"?: { tools?: Record<string, unknown> };
|
||||
};
|
||||
const configAgent = config.agent as AgentConfig | undefined;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
};
|
||||
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } =
|
||||
configAgent?.build ?? {};
|
||||
const migratedBuildConfig = migrateAgentConfig(
|
||||
buildConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const openCodeBuilderOverride =
|
||||
pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...migratedBuildConfig,
|
||||
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, mode: _planMode, ...planConfigWithoutName } =
|
||||
configAgent?.plan ?? {};
|
||||
const migratedPlanConfig = migrateAgentConfig(
|
||||
planConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const plannerSisyphusOverride =
|
||||
pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const defaultModel = config.model as string | undefined;
|
||||
const plannerSisyphusBase = {
|
||||
model: (migratedPlanConfig as Record<string, unknown>).model ?? defaultModel,
|
||||
mode: "all" as const,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: (configAgent?.plan?.color as string) ?? "#6495ED",
|
||||
};
|
||||
|
||||
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent)
|
||||
.filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
value ? migrateAgentConfig(value as Record<string, unknown>) : value,
|
||||
])
|
||||
)
|
||||
: {};
|
||||
|
||||
const migratedBuild = configAgent?.build
|
||||
? migrateAgentConfig(configAgent.build as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { mode: "subagent" as const, hidden: true }
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(
|
||||
Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")
|
||||
),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents,
|
||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...configAgent,
|
||||
};
|
||||
}
|
||||
|
||||
const agentResult = config.agent as AgentConfig;
|
||||
|
||||
config.tools = {
|
||||
...(config.tools as Record<string, unknown>),
|
||||
"grep_app_*": false,
|
||||
};
|
||||
|
||||
if (agentResult.explore) {
|
||||
agentResult.explore.tools = {
|
||||
...agentResult.explore.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (agentResult.librarian) {
|
||||
agentResult.librarian.tools = {
|
||||
...agentResult.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
"grep_app_*": true,
|
||||
};
|
||||
}
|
||||
if (agentResult["multimodal-looker"]) {
|
||||
agentResult["multimodal-looker"].tools = {
|
||||
...agentResult["multimodal-looker"].tools,
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
};
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
...(config.permission as Record<string, unknown>),
|
||||
webfetch: "allow",
|
||||
external_directory: "allow",
|
||||
};
|
||||
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadUserCommands()
|
||||
: {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = (config.command as Record<string, unknown>) ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadProjectCommands()
|
||||
: {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadUserSkills()
|
||||
: {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadProjectSkills()
|
||||
: {};
|
||||
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
||||
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...opencodeGlobalSkills,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeProjectSkills,
|
||||
...pluginComponents.commands,
|
||||
...pluginComponents.skills,
|
||||
};
|
||||
};
|
||||
}
|
||||
1
src/plugin-handlers/index.ts
Normal file
1
src/plugin-handlers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createConfigHandler, type ConfigHandlerDeps } from "./config-handler";
|
||||
30
src/plugin-state.ts
Normal file
30
src/plugin-state.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface ModelCacheState {
|
||||
modelContextLimitsCache: Map<string, number>;
|
||||
anthropicContext1MEnabled: boolean;
|
||||
}
|
||||
|
||||
export function createModelCacheState(): ModelCacheState {
|
||||
return {
|
||||
modelContextLimitsCache: new Map<string, number>(),
|
||||
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;
|
||||
}
|
||||
@@ -2,81 +2,62 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Cross-cutting utility functions used across agents, hooks, tools, and features. Path resolution, config management, text processing, and Claude Code compatibility helpers.
|
||||
Cross-cutting utilities: path resolution, config management, text processing, Claude Code compatibility helpers.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export (import { x } from "../shared")
|
||||
├── claude-config-dir.ts # Resolve ~/.claude directory
|
||||
├── command-executor.ts # Shell command execution with variable expansion
|
||||
├── config-errors.ts # Global config error tracking
|
||||
├── config-path.ts # User/project config path resolution
|
||||
├── data-path.ts # XDG data directory resolution
|
||||
├── deep-merge.ts # Type-safe recursive object merging
|
||||
├── dynamic-truncator.ts # Token-aware output truncation
|
||||
├── file-reference-resolver.ts # @filename syntax resolution
|
||||
├── file-utils.ts # Symlink resolution, markdown detection
|
||||
├── index.ts # Barrel export
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── command-executor.ts # Shell exec with variable expansion
|
||||
├── config-errors.ts # Global error tracking
|
||||
├── config-path.ts # User/project config paths
|
||||
├── data-path.ts # XDG data directory
|
||||
├── deep-merge.ts # Type-safe recursive merge
|
||||
├── dynamic-truncator.ts # Token-aware truncation
|
||||
├── file-reference-resolver.ts # @filename syntax
|
||||
├── file-utils.ts # Symlink, markdown detection
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook is disabled in config
|
||||
├── jsonc-parser.ts # JSON with Comments parsing
|
||||
├── logger.ts # File-based logging to OS temp
|
||||
├── migration.ts # Legacy name compatibility (omo -> Sisyphus)
|
||||
├── hook-disabled.ts # Check if hook disabled
|
||||
├── jsonc-parser.ts # JSON with Comments
|
||||
├── logger.ts # File-based logging
|
||||
├── migration.ts # Legacy name compat (omo → Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── pattern-matcher.ts # Tool name matching with wildcards
|
||||
├── snake-case.ts # Case conversion for objects
|
||||
└── tool-name.ts # Normalize tool names to PascalCase
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── snake-case.ts # Case conversion
|
||||
└── tool-name.ts # PascalCase normalization
|
||||
```
|
||||
|
||||
## UTILITY CATEGORIES
|
||||
## WHEN TO USE
|
||||
|
||||
| Category | Utilities | Used By |
|
||||
|----------|-----------|---------|
|
||||
| Path Resolution | `getClaudeConfigDir`, `getUserConfigPath`, `getProjectConfigPath`, `getDataDir` | Features, Hooks |
|
||||
| Config Management | `deepMerge`, `parseJsonc`, `isHookDisabled`, `configErrors` | index.ts, CLI |
|
||||
| Text Processing | `resolveCommandsInText`, `resolveFileReferencesInText`, `parseFrontmatter` | Commands, Rules |
|
||||
| Output Control | `dynamicTruncate` | Tools (Grep, LSP) |
|
||||
| Normalization | `transformToolName`, `objectToSnakeCase`, `sanitizeModelName` | Hooks, Agents |
|
||||
| Compatibility | `migration.ts` | Config loading |
|
||||
|
||||
## WHEN TO USE WHAT
|
||||
|
||||
| Task | Utility | Notes |
|
||||
|------|---------|-------|
|
||||
| Find Claude Code configs | `getClaudeConfigDir()` | Never hardcode `~/.claude` |
|
||||
| Merge settings (default → user → project) | `deepMerge(base, override)` | Arrays replaced, objects merged |
|
||||
| Parse user config files | `parseJsonc()` | Supports comments and trailing commas |
|
||||
| Check if hook should run | `isHookDisabled(name, disabledHooks)` | Respects `disabled_hooks` config |
|
||||
| Truncate large tool output | `dynamicTruncate(text, budget, reserved)` | Token-aware, prevents overflow |
|
||||
| Resolve `@file` references | `resolveFileReferencesInText()` | maxDepth=3 prevents infinite loops |
|
||||
| Execute shell commands | `resolveCommandsInText()` | Supports `!`\`command\`\` syntax |
|
||||
| Handle legacy agent names | `migrateLegacyAgentNames()` | `omo` → `Sisyphus` |
|
||||
| Task | Utility |
|
||||
|------|---------|
|
||||
| Find ~/.claude | `getClaudeConfigDir()` |
|
||||
| Merge configs | `deepMerge(base, override)` |
|
||||
| Parse user files | `parseJsonc()` |
|
||||
| Check hook enabled | `isHookDisabled(name, list)` |
|
||||
| Truncate output | `dynamicTruncate(text, budget)` |
|
||||
| Resolve @file | `resolveFileReferencesInText()` |
|
||||
| Execute shell | `resolveCommandsInText()` |
|
||||
| Legacy names | `migrateLegacyAgentNames()` |
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
|
||||
### Dynamic Truncation
|
||||
```typescript
|
||||
import { dynamicTruncate } from "../shared"
|
||||
// Keep 50% headroom, max 50k tokens
|
||||
// Dynamic truncation
|
||||
const output = dynamicTruncate(result, remainingTokens, 0.5)
|
||||
```
|
||||
|
||||
### Deep Merge Priority
|
||||
```typescript
|
||||
const final = deepMerge(defaults, userConfig)
|
||||
final = deepMerge(final, projectConfig) // Project wins
|
||||
```
|
||||
// Deep merge priority
|
||||
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
### Safe JSONC Parsing
|
||||
```typescript
|
||||
// Safe JSONC
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
if (error) return fallback
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS (SHARED)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Hardcoding paths**: Use `getClaudeConfigDir()`, `getUserConfigPath()`
|
||||
- **Manual JSON.parse**: Use `parseJsonc()` for user files (comments allowed)
|
||||
- **Ignoring truncation**: Large outputs MUST use `dynamicTruncate`
|
||||
- **Direct string concat for configs**: Use `deepMerge` for proper priority
|
||||
- Hardcoding paths (use getClaudeConfigDir, getUserConfigPath)
|
||||
- JSON.parse for user files (use parseJsonc)
|
||||
- Ignoring truncation (large outputs MUST use dynamicTruncate)
|
||||
- Direct string concat for configs (use deepMerge)
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
const ANTHROPIC_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;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user