Compare commits

..

1 Commits

Author SHA1 Message Date
YeonGyu-Kim
5c2a215fca fix(run): add stabilization period to prevent early exit
oh-my-opencode run exits within ~1.5s of starting because pollForCompletion
checks completion too early. When the agent outputs its first text but hasn't
created todos yet, the empty state (0 todos, 0 children) passes all checks.

Root cause timeline:
1. promptAsync fires
2. Agent outputs text (e.g. 'ULTRAWORK MODE ENABLED!') -> hasReceivedMeaningfulWork = true
3. Agent pauses before todowrite -> session briefly goes idle
4. pollForCompletion: idle=true, tool=null, work=true -> checkCompletionConditions
5. 0 todos = 'all complete', 0 children = 'all idle' -> true
6. 3 consecutive checks (1.5s) -> premature exit 0

Fix: Add a minimum stabilization period (10s) after the first meaningful work
before checking completion conditions. This gives agents time to create todos
and spawn child sessions. The period is configurable via PollOptions for tests.

Note: todo 0 remaining 'all complete' is correct behavior — some agents don't
use todos. The stabilization period is the proper fix, not changing completion
semantics.
2026-02-11 16:57:39 +09:00
227 changed files with 5199 additions and 13694 deletions

View File

@@ -52,31 +52,12 @@ jobs:
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/cli/doctor/formatter.test.ts
bun test src/cli/doctor/format-default.test.ts
bun test src/tools/call-omo-agent/sync-executor.test.ts
bun test src/tools/call-omo-agent/session-creator.test.ts
bun test src/features/opencode-skill-loader/loader.test.ts
- name: Run remaining tests
run: |
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
# that were already run in isolation above.
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
bun test bin script src/config src/mcp src/index.test.ts \
src/agents src/shared \
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
src/cli/config-manager.test.ts \
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
src/tools/glob src/tools/grep src/tools/interactive-bash \
src/tools/look-at src/tools/lsp src/tools/session-manager \
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
src/tools/call-omo-agent/background-agent-executor.test.ts \
src/tools/call-omo-agent/background-executor.test.ts \
src/tools/call-omo-agent/subagent-session-creator.test.ts \
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -89,11 +70,7 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader/config-source-discovery.test.ts \
src/features/opencode-skill-loader/merger.test.ts \
src/features/opencode-skill-loader/skill-content.test.ts \
src/features/opencode-skill-loader/blocking.test.ts \
src/features/opencode-skill-loader/async-loader.test.ts \
src/features/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:

View File

@@ -51,33 +51,13 @@ jobs:
# Run them in separate processes to prevent cross-file contamination
bun test src/plugin-handlers
bun test src/hooks/atlas
bun test src/hooks/compaction-context-injector
bun test src/features/tmux-subagent
bun test src/cli/doctor/formatter.test.ts
bun test src/cli/doctor/format-default.test.ts
bun test src/tools/call-omo-agent/sync-executor.test.ts
bun test src/tools/call-omo-agent/session-creator.test.ts
bun test src/features/opencode-skill-loader/loader.test.ts
- name: Run remaining tests
run: |
# Enumerate subdirectories/files explicitly to EXCLUDE mock-heavy files
# that were already run in isolation above.
# Excluded from src/cli: doctor/formatter.test.ts, doctor/format-default.test.ts
# Excluded from src/tools: call-omo-agent/sync-executor.test.ts, call-omo-agent/session-creator.test.ts
bun test bin script src/config src/mcp src/index.test.ts \
src/agents src/shared \
src/cli/run src/cli/config-manager src/cli/mcp-oauth \
src/cli/index.test.ts src/cli/install.test.ts src/cli/model-fallback.test.ts \
src/cli/config-manager.test.ts \
src/cli/doctor/runner.test.ts src/cli/doctor/checks \
src/tools/ast-grep src/tools/background-task src/tools/delegate-task \
src/tools/glob src/tools/grep src/tools/interactive-bash \
src/tools/look-at src/tools/lsp src/tools/session-manager \
src/tools/skill src/tools/skill-mcp src/tools/slashcommand src/tools/task \
src/tools/call-omo-agent/background-agent-executor.test.ts \
src/tools/call-omo-agent/background-executor.test.ts \
src/tools/call-omo-agent/subagent-session-creator.test.ts \
# Run all other tests (mock-heavy ones are re-run but that's acceptable)
bun test bin script src/cli src/config src/mcp src/index.test.ts \
src/agents src/tools src/shared \
src/hooks/anthropic-context-window-limit-recovery \
src/hooks/claude-code-compatibility \
src/hooks/context-injection \
@@ -90,11 +70,7 @@ jobs:
src/features/builtin-skills \
src/features/claude-code-session-state \
src/features/hook-message-injector \
src/features/opencode-skill-loader/config-source-discovery.test.ts \
src/features/opencode-skill-loader/merger.test.ts \
src/features/opencode-skill-loader/skill-content.test.ts \
src/features/opencode-skill-loader/blocking.test.ts \
src/features/opencode-skill-loader/async-loader.test.ts \
src/features/opencode-skill-loader \
src/features/skill-mcp-manager
typecheck:
@@ -247,23 +223,118 @@ jobs:
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Generate changelog
id: changelog
run: |
bun run script/generate-changelog.ts > /tmp/changelog.md
VERSION="${{ needs.publish-main.outputs.version }}"
PREV_TAG=""
if [[ "$VERSION" == *"-beta."* ]]; then
BASE="${VERSION%-beta.*}"
NUM="${VERSION##*-beta.}"
PREV_NUM=$((NUM - 1))
if [ $PREV_NUM -ge 1 ]; then
PREV_TAG="${BASE}-beta.${PREV_NUM}"
git rev-parse "v${PREV_TAG}" >/dev/null 2>&1 || PREV_TAG=""
fi
fi
if [ -z "$PREV_TAG" ]; then
PREV_TAG=$(curl -s https://registry.npmjs.org/oh-my-opencode/latest | jq -r '.version // "0.0.0"')
fi
echo "Comparing v${PREV_TAG}..v${VERSION}"
# Get all commits between tags
COMMITS=$(git log "v${PREV_TAG}..v${VERSION}" --format="%s" 2>/dev/null || echo "")
# Initialize sections
FEATURES=""
FIXES=""
REFACTOR=""
DOCS=""
OTHER=""
# Store regexes in variables for bash 5.2+ compatibility
# (bash 5.2 changed how parentheses are parsed inside [[ =~ ]])
re_skip='^(chore|ci|release|test|ignore)'
re_feat_scoped='^feat\(([^)]+)\): (.+)$'
re_fix_scoped='^fix\(([^)]+)\): (.+)$'
re_refactor_scoped='^refactor\(([^)]+)\): (.+)$'
re_docs_scoped='^docs\(([^)]+)\): (.+)$'
while IFS= read -r commit; do
[ -z "$commit" ] && continue
# Skip chore, ci, release, test commits
[[ "$commit" =~ $re_skip ]] && continue
if [[ "$commit" =~ ^feat ]]; then
# Extract scope and message: feat(scope): message -> **scope**: message
if [[ "$commit" =~ $re_feat_scoped ]]; then
FEATURES="${FEATURES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else
MSG="${commit#feat: }"
FEATURES="${FEATURES}\n- ${MSG}"
fi
elif [[ "$commit" =~ ^fix ]]; then
if [[ "$commit" =~ $re_fix_scoped ]]; then
FIXES="${FIXES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else
MSG="${commit#fix: }"
FIXES="${FIXES}\n- ${MSG}"
fi
elif [[ "$commit" =~ ^refactor ]]; then
if [[ "$commit" =~ $re_refactor_scoped ]]; then
REFACTOR="${REFACTOR}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else
MSG="${commit#refactor: }"
REFACTOR="${REFACTOR}\n- ${MSG}"
fi
elif [[ "$commit" =~ ^docs ]]; then
if [[ "$commit" =~ $re_docs_scoped ]]; then
DOCS="${DOCS}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else
MSG="${commit#docs: }"
DOCS="${DOCS}\n- ${MSG}"
fi
else
OTHER="${OTHER}\n- ${commit}"
fi
done <<< "$COMMITS"
# Build release notes
{
echo "## What's Changed"
echo ""
if [ -n "$FEATURES" ]; then
echo "### Features"
echo -e "$FEATURES"
echo ""
fi
if [ -n "$FIXES" ]; then
echo "### Bug Fixes"
echo -e "$FIXES"
echo ""
fi
if [ -n "$REFACTOR" ]; then
echo "### Refactoring"
echo -e "$REFACTOR"
echo ""
fi
if [ -n "$DOCS" ]; then
echo "### Documentation"
echo -e "$DOCS"
echo ""
fi
if [ -n "$OTHER" ]; then
echo "### Other Changes"
echo -e "$OTHER"
echo ""
fi
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/v${PREV_TAG}...v${VERSION}"
} > /tmp/changelog.md
cat /tmp/changelog.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub release
run: |

View File

@@ -31,9 +31,9 @@ You are the release manager for oh-my-opencode. Execute the FULL publish workflo
{ "id": "sync-remote", "content": "Sync with remote (pull --rebase && push if unpushed commits)", "status": "pending", "priority": "high" },
{ "id": "run-workflow", "content": "Trigger GitHub Actions publish workflow", "status": "pending", "priority": "high" },
{ "id": "wait-workflow", "content": "Wait for workflow completion (poll every 30s)", "status": "pending", "priority": "high" },
{ "id": "verify-and-preview", "content": "Verify release created + preview auto-generated changelog & contributor thanks", "status": "pending", "priority": "high" },
{ "id": "draft-summary", "content": "Draft enhanced release summary (mandatory for minor/major, optional for patch — ask user)", "status": "pending", "priority": "high" },
{ "id": "apply-summary", "content": "Prepend enhanced summary to release (if user opted in)", "status": "pending", "priority": "high" },
{ "id": "verify-release", "content": "Verify GitHub release was created", "status": "pending", "priority": "high" },
{ "id": "draft-release-notes", "content": "Draft enhanced release notes content", "status": "pending", "priority": "high" },
{ "id": "update-release-notes", "content": "Update GitHub release with enhanced notes", "status": "pending", "priority": "high" },
{ "id": "verify-npm", "content": "Verify npm package published successfully", "status": "pending", "priority": "high" },
{ "id": "wait-platform-workflow", "content": "Wait for publish-platform workflow completion", "status": "pending", "priority": "high" },
{ "id": "verify-platform-binaries", "content": "Verify all 7 platform binary packages published", "status": "pending", "priority": "high" },
@@ -111,166 +111,103 @@ gh run view {run_id} --log-failed
---
## STEP 5: VERIFY RELEASE & PREVIEW AUTO-GENERATED CONTENT
Two goals: confirm the release exists, then show the user what the workflow already generated.
## STEP 5: VERIFY GITHUB RELEASE
Get the new version and verify release exists:
```bash
# Pull latest (workflow committed version bump)
# Get new version from package.json (workflow updates it)
git pull --rebase
NEW_VERSION=$(node -p "require('./package.json').version")
# Verify release exists on GitHub
gh release view "v${NEW_VERSION}" --json tagName,url --jq '{tag: .tagName, url: .url}'
gh release view "v${NEW_VERSION}"
```
**After verifying, generate a local preview of the auto-generated content:**
```bash
bun run script/generate-changelog.ts
```
<agent-instruction>
After running the preview, present the output to the user and say:
> **The following content is ALREADY included in the release automatically:**
> - Commit changelog (grouped by feat/fix/refactor)
> - Contributor thank-you messages (for non-team contributors)
>
> You do NOT need to write any of this. It's handled.
>
> **For a patch release**, this is usually sufficient on its own. However, if there are notable bug fixes or changes worth highlighting, an enhanced summary can be added.
> **For a minor/major release**, an enhanced summary is **required** — I'll draft one in the next step.
Wait for the user to acknowledge before proceeding.
</agent-instruction>
---
## STEP 6: DRAFT ENHANCED RELEASE SUMMARY
## STEP 6: DRAFT ENHANCED RELEASE NOTES
<decision-gate>
Analyze commits since the previous version and draft release notes following project conventions:
| Release Type | Action |
|-------------|--------|
| **patch** | ASK the user: "Would you like me to draft an enhanced summary highlighting the key bug fixes / changes? Or is the auto-generated changelog sufficient?" If user declines → skip to Step 8. If user accepts → draft a concise bug-fix / change summary below. |
| **minor** | MANDATORY. Draft a concise feature summary. Do NOT proceed without one. |
| **major** | MANDATORY. Draft a full release narrative with migration notes if applicable. Do NOT proceed without one. |
### For PATCH releases:
Keep simple format - just list commits:
```markdown
- {hash} {conventional commit message}
- ...
```
</decision-gate>
### For MINOR releases:
Use feature-focused format:
```markdown
## New Features
### What You're Writing (and What You're NOT)
### Feature Name
- Description of what it does
- Why it matters
You are writing the **headline layer** — a product announcement that sits ABOVE the auto-generated commit log. Think "release blog post", not "git log".
## Bug Fixes
- fix(scope): description
<rules>
- NEVER duplicate commit messages. The auto-generated section already lists every commit.
- NEVER write generic filler like "Various bug fixes and improvements" or "Several enhancements".
- ALWAYS focus on USER IMPACT: what can users DO now that they couldn't before?
- ALWAYS group by THEME or CAPABILITY, not by commit type (feat/fix/refactor).
- ALWAYS use concrete language: "You can now do X" not "Added X feature".
</rules>
<examples>
<bad title="Commit regurgitation — DO NOT do this">
## What's New
- feat(auth): add JWT refresh token rotation
- fix(auth): handle expired token edge case
- refactor(auth): extract middleware
</bad>
<good title="User-impact narrative — DO this">
## 🔐 Smarter Authentication
Token refresh is now automatic and seamless. Sessions no longer expire mid-task — the system silently rotates credentials in the background. If you've been frustrated by random logouts, this release fixes that.
</good>
<bad title="Vague filler — DO NOT do this">
## Improvements
- Various performance improvements
- Bug fixes and stability enhancements
</bad>
<good title="Specific and measurable — DO this">
## ⚡ 3x Faster Rule Parsing
Rules are now cached by file modification time. If your project has 50+ rule files, you'll notice startup is noticeably faster — we measured a 3x improvement in our test suite.
</good>
</examples>
### Drafting Process
1. **Analyze** the commit list from Step 5's preview. Identify 2-5 themes that matter to users.
2. **Write** the summary to `/tmp/release-summary-v${NEW_VERSION}.md`.
3. **Present** the draft to the user for review and approval before applying.
```bash
# Write your draft here
cat > /tmp/release-summary-v${NEW_VERSION}.md << 'SUMMARY_EOF'
{your_enhanced_summary}
SUMMARY_EOF
cat /tmp/release-summary-v${NEW_VERSION}.md
- refactor(scope): description
```
<agent-instruction>
After drafting, ask the user:
> "Here's the release summary I drafted. This will appear AT THE TOP of the release notes, above the auto-generated commit changelog and contributor thanks. Want me to adjust anything before applying?"
### For MAJOR releases:
Full changelog format:
```markdown
# v{version}
Do NOT proceed to Step 7 without user confirmation.
</agent-instruction>
Brief description of the release.
## What's New Since v{previous}
### Breaking Changes
- Description of breaking change
### Features
- **Feature Name**: Description
### Bug Fixes
- Description
### Documentation
- Description
## Migration Guide (if applicable)
...
```
**CRITICAL: The enhanced notes must ADD to existing workflow-generated notes, not replace them.**
---
## STEP 7: APPLY ENHANCED SUMMARY TO RELEASE
## STEP 7: UPDATE GITHUB RELEASE
**Skip this step ONLY if the user opted out of the enhanced summary in Step 6** — proceed directly to Step 8.
<architecture>
The final release note structure:
```
┌─────────────────────────────────────┐
│ Enhanced Summary (from Step 6) │ ← You wrote this
│ - Theme-based, user-impact focused │
├─────────────────────────────────────┤
│ --- (separator) │
├─────────────────────────────────────┤
│ Auto-generated Commit Changelog │ ← Workflow wrote this
│ - feat/fix/refactor grouped │
│ - Contributor thank-you messages │
└─────────────────────────────────────┘
```
</architecture>
<zero-content-loss-policy>
- Fetch the existing release body FIRST
- PREPEND your summary above it
- The existing auto-generated content must remain 100% INTACT
- NOT A SINGLE CHARACTER of existing content may be removed or modified
</zero-content-loss-policy>
**ZERO CONTENT LOSS POLICY:**
- First, fetch the existing release body with `gh release view`
- Your enhanced notes must be PREPENDED to the existing content
- **NOT A SINGLE CHARACTER of existing content may be removed or modified**
- The final release body = `{your_enhanced_notes}\n\n---\n\n{existing_body_exactly_as_is}`
```bash
# 1. Fetch existing auto-generated body
# Get existing body
EXISTING_BODY=$(gh release view "v${NEW_VERSION}" --json body --jq '.body')
# 2. Combine: enhanced summary on top, auto-generated below
{
cat /tmp/release-summary-v${NEW_VERSION}.md
echo ""
echo "---"
echo ""
echo "$EXISTING_BODY"
} > /tmp/final-release-v${NEW_VERSION}.md
# Write enhanced notes to temp file (prepend to existing)
cat > /tmp/release-notes-v${NEW_VERSION}.md << 'EOF'
{your_enhanced_notes}
# 3. Update the release (additive only)
gh release edit "v${NEW_VERSION}" --notes-file /tmp/final-release-v${NEW_VERSION}.md
---
# 4. Confirm
echo "✅ Release v${NEW_VERSION} updated with enhanced summary."
gh release view "v${NEW_VERSION}" --json url --jq '.url'
EOF
# Append existing body EXACTLY as-is (zero modifications)
echo "$EXISTING_BODY" >> /tmp/release-notes-v${NEW_VERSION}.md
# Update release
gh release edit "v${NEW_VERSION}" --notes-file /tmp/release-notes-v${NEW_VERSION}.md
```
**CRITICAL: This is ADDITIVE ONLY. You are adding your notes on top. The existing content remains 100% intact.**
---
## STEP 8: VERIFY NPM PUBLICATION

View File

@@ -280,10 +280,10 @@ To remove oh-my-opencode:
```bash
# Remove user config
rm -f ~/.config/opencode/oh-my-opencode.json ~/.config/opencode/oh-my-opencode.jsonc
rm -f ~/.config/opencode/oh-my-opencode.json
# Remove project config (if exists)
rm -f .opencode/oh-my-opencode.json .opencode/oh-my-opencode.jsonc
rm -f .opencode/oh-my-opencode.json
```
3. **Verify removal**
@@ -314,7 +314,7 @@ Highly opinionated, but adjustable to taste.
See the full [Configuration Documentation](docs/configurations.md) for detailed information.
**Quick Overview:**
- **Config Locations**: `.opencode/oh-my-opencode.jsonc` or `.opencode/oh-my-opencode.json` (project), `~/.config/opencode/oh-my-opencode.jsonc` or `~/.config/opencode/oh-my-opencode.json` (user)
- **Config Locations**: `.opencode/oh-my-opencode.json` (project) or `~/.config/opencode/oh-my-opencode.json` (user)
- **JSONC Support**: Comments and trailing commas supported
- **Agents**: Override models, temperatures, prompts, and permissions for any agent
- **Built-in Skills**: `playwright` (browser automation), `git-master` (atomic commits)

File diff suppressed because it is too large Load Diff

View File

@@ -28,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.5",
"oh-my-opencode-darwin-x64": "3.5.5",
"oh-my-opencode-linux-arm64": "3.5.5",
"oh-my-opencode-linux-arm64-musl": "3.5.5",
"oh-my-opencode-linux-x64": "3.5.5",
"oh-my-opencode-linux-x64-musl": "3.5.5",
"oh-my-opencode-windows-x64": "3.5.5",
"oh-my-opencode-darwin-arm64": "3.5.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1",
},
},
},
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XtcCQ8/iVT6T1B58y0N1oMgOK4beTW8DW98b/ITnINb7b3hNSv5754Af/2Rx67BV0iE0ezC6uXaqz45C7ru1rw=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.5.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oH+c/+Z/ULIK+8T1jQFpzISHsvQPyYJfA6bceiD9sgFy1OY1NjRh4a3sFk8cXy6uRVKpivWDFOfbVTcZ2kbKWA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ReSDqU6jihh7lpGNmEt3REzc5bOcyfv3cMHitpecKq0wRrJoTBI+dgNPk90BLjHobGbhAm0TE8VZ9tqTkivnIQ=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.5.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wnBYQ9BZBLbzgSNIJZOIJS03zf+b4trAQeYmG+yCLn8y7FWXqw1KmjJ88/bbMXTuZ4RSMKWpXb1Afgdsred+DQ=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Zs/ETIxwcWBvw+jdlo8t+3+92oMMaXkFg1ZCuZrBRZOmtPFefdsH5/QEIe2TlNSjfoTwlA7cbpOD6oXgxRVrtg=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-19KNJex1LeU/S14IsJbumOvZa9O6F7X4BLIY7MfjtHtTk0dRFL+tbbXmlafecBMigEKlLdJ+HTW3TnQgp7Ih8A=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m9r4OW1XhGtm/SvHM3kzpS4pEiI2eIh5Tj+j5hpMW3wu+AqE3F1XGUpu8RgvIpupFo8beimJWDYQujqokReQqg=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.5.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mCCnym3nBTJP+xzK+AS4YPFQiT2sZWmjhOhOy7PjNY6Is4jkfT1C2e9ZrIU/2VoVLV6V5q7hQGh1jgleU+FxwQ=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6ysF5Pr2C1dyC5Dftzp05RJODgL+EYCWcOV59/UCV152cINlOhg80804o+6XTKV/taOAaboYaQwsBKiCs/BNQ=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sDYt4adNuwb+p1RzHb7IR9zvbAnYYgZofjPvceirBorffp63f+aypYFxjFpfmbT87o/Eb/Hgzm4sHliJtd1UmQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-MOxW1FMTJT3Ze/U2fDedcZUYTFaA9PaKIiqtsBIHOSb+fFgdo51RIuUlKCELN/g9I9dYhw0yP2n9tBMBG6feSg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.5.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-tz/0QSS5AKIiKj6cMom5VQSnEYpMIP/SRTaP5WYNOYhnUkXMwXEncQ7FIcj2vovMCXuqA9a8ujVY0zTs7TeALw=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-dWRtPyIdMFQIw1BwVO4PbGqoo0UWs7NES+YJC7BLGv0YnWN7Q2tatmOviSeSgMELeMsWSbDNisEB79jsfShXjA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.5.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-zfpRS6HIkSwE8btajJzSYxhqsE5kDkop896/XGS3LLIAAZt0RtCmT3C1plxVfI9oAABfgcaiveCxJ5f9AlKPcQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -38,13 +38,13 @@ It asks about your providers (Claude, OpenAI, Gemini, etc.) and generates optima
## Config File Locations
Config file locations (priority order):
1. `.opencode/oh-my-opencode.jsonc` or `.opencode/oh-my-opencode.json` (project; prefers `.jsonc` when both exist)
2. User config (platform-specific; prefers `.jsonc` when both exist):
1. `.opencode/oh-my-opencode.json` (project)
2. User config (platform-specific):
| Platform | User Config Path |
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
| **Windows** | `~/.config/opencode/oh-my-opencode.jsonc` (preferred) or `~/.config/opencode/oh-my-opencode.json` (fallback); `%APPDATA%\opencode\oh-my-opencode.jsonc` / `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.jsonc` (preferred) or `~/.config/opencode/oh-my-opencode.json` (fallback) |
| Platform | User Config Path |
| --------------- | ----------------------------------------------------------------------------------------------------------- |
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (preferred) or `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
Schema autocomplete supported:
@@ -83,7 +83,7 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
## Google Auth
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](guide/installation.md#google-gemini-antigravity-oauth).
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
## Ollama Provider
@@ -1061,10 +1061,9 @@ Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-openco
OpenCode provides LSP tools for analysis.
Oh My OpenCode adds refactoring tools (rename, code actions).
All OpenCode LSP configs and custom settings (from `opencode.jsonc` / `opencode.json`) are supported, plus additional Oh My OpenCode-specific settings.
For config discovery, `.jsonc` takes precedence over `.json` when both exist (applies to both `opencode.*` and `oh-my-opencode.*`).
All OpenCode LSP configs and custom settings (from opencode.json) are supported, plus additional Oh My OpenCode-specific settings.
Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.jsonc` / `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.jsonc` / `.opencode/oh-my-opencode.json`:
Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.5.6",
"version": "3.5.1",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.5.6",
"oh-my-opencode-darwin-x64": "3.5.6",
"oh-my-opencode-linux-arm64": "3.5.6",
"oh-my-opencode-linux-arm64-musl": "3.5.6",
"oh-my-opencode-linux-x64": "3.5.6",
"oh-my-opencode-linux-x64-musl": "3.5.6",
"oh-my-opencode-windows-x64": "3.5.6"
"oh-my-opencode-darwin-arm64": "3.5.1",
"oh-my-opencode-darwin-x64": "3.5.1",
"oh-my-opencode-linux-arm64": "3.5.1",
"oh-my-opencode-linux-arm64-musl": "3.5.1",
"oh-my-opencode-linux-x64": "3.5.1",
"oh-my-opencode-linux-x64-musl": "3.5.1",
"oh-my-opencode-windows-x64": "3.5.1"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.5.6",
"version": "3.5.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,17 +0,0 @@
import * as z from "zod"
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
export function createOhMyOpenCodeJsonSchema(): Record<string, unknown> {
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, {
target: "draft-07",
unrepresentable: "any",
})
return {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
title: "Oh My OpenCode Configuration",
description: "Configuration schema for oh-my-opencode plugin",
...jsonSchema,
}
}

View File

@@ -1,18 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createOhMyOpenCodeJsonSchema } from "./build-schema-document"
describe("build-schema-document", () => {
test("generates schema with skills property", () => {
// given
const expectedDraft = "http://json-schema.org/draft-07/schema#"
// when
const schema = createOhMyOpenCodeJsonSchema()
// then
expect(schema.$schema).toBe(expectedDraft)
expect(schema.title).toBe("Oh My OpenCode Configuration")
expect(schema.properties).toBeDefined()
expect(schema.properties.skills).toBeDefined()
})
})

View File

@@ -1,12 +1,24 @@
#!/usr/bin/env bun
import { createOhMyOpenCodeJsonSchema } from "./build-schema-document"
import * as z from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
async function main() {
console.log("Generating JSON Schema...")
const finalSchema = createOhMyOpenCodeJsonSchema()
const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema, {
target: "draft7",
})
const finalSchema = {
$schema: "http://json-schema.org/draft-07/schema#",
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
title: "Oh My OpenCode Configuration",
description: "Configuration schema for oh-my-opencode plugin",
...jsonSchema,
}
await Bun.write(SCHEMA_OUTPUT_PATH, JSON.stringify(finalSchema, null, 2))

View File

@@ -1359,166 +1359,6 @@
"created_at": "2026-02-11T05:29:51Z",
"repoId": 1108837393,
"pullRequestNo": 1750
},
{
"name": "uyu423",
"id": 8033320,
"comment_id": 3884127858,
"created_at": "2026-02-11T12:30:37Z",
"repoId": 1108837393,
"pullRequestNo": 1762
},
{
"name": "WietRob",
"id": 203506602,
"comment_id": 3859280254,
"created_at": "2026-02-06T10:00:03Z",
"repoId": 1108837393,
"pullRequestNo": 1529
},
{
"name": "COLDTURNIP",
"id": 46220,
"comment_id": 3884966424,
"created_at": "2026-02-11T14:54:46Z",
"repoId": 1108837393,
"pullRequestNo": 1765
},
{
"name": "tcarac",
"id": 64477810,
"comment_id": 3885026481,
"created_at": "2026-02-11T15:03:25Z",
"repoId": 1108837393,
"pullRequestNo": 1766
},
{
"name": "youngbinkim0",
"id": 64558592,
"comment_id": 3887466814,
"created_at": "2026-02-11T22:03:00Z",
"repoId": 1108837393,
"pullRequestNo": 1777
},
{
"name": "raki-1203",
"id": 52475378,
"comment_id": 3889111683,
"created_at": "2026-02-12T07:27:39Z",
"repoId": 1108837393,
"pullRequestNo": 1790
},
{
"name": "G36maid",
"id": 53391375,
"comment_id": 3889208379,
"created_at": "2026-02-12T07:56:21Z",
"repoId": 1108837393,
"pullRequestNo": 1791
},
{
"name": "solssak",
"id": 107416133,
"comment_id": 3889740003,
"created_at": "2026-02-12T09:28:09Z",
"repoId": 1108837393,
"pullRequestNo": 1794
},
{
"name": "bvanderhorn",
"id": 9591412,
"comment_id": 3890297580,
"created_at": "2026-02-12T11:17:38Z",
"repoId": 1108837393,
"pullRequestNo": 1799
},
{
"name": "jardo5",
"id": 22041729,
"comment_id": 3890810423,
"created_at": "2026-02-12T12:57:06Z",
"repoId": 1108837393,
"pullRequestNo": 1802
},
{
"name": "willy-scr",
"id": 187001140,
"comment_id": 3894534811,
"created_at": "2026-02-13T02:56:20Z",
"repoId": 1108837393,
"pullRequestNo": 1809
},
{
"name": "professional-ALFIE",
"id": 219141081,
"comment_id": 3897671676,
"created_at": "2026-02-13T15:00:01Z",
"repoId": 1108837393,
"pullRequestNo": 1820
},
{
"name": "Strocs",
"id": 71996940,
"comment_id": 3898248552,
"created_at": "2026-02-13T16:56:54Z",
"repoId": 1108837393,
"pullRequestNo": 1822
},
{
"name": "cloudwaddie-agent",
"id": 261346076,
"comment_id": 3900805128,
"created_at": "2026-02-14T04:15:19Z",
"repoId": 1108837393,
"pullRequestNo": 1827
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872741516,
"created_at": "2026-02-09T16:21:56Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "morphaxl",
"id": 57144942,
"comment_id": 3872742242,
"created_at": "2026-02-09T16:22:04Z",
"repoId": 1108837393,
"pullRequestNo": 1699
},
{
"name": "liu-qingyuan",
"id": 57737268,
"comment_id": 3902402078,
"created_at": "2026-02-14T19:39:58Z",
"repoId": 1108837393,
"pullRequestNo": 1844
},
{
"name": "iyoda",
"id": 31020,
"comment_id": 3902426789,
"created_at": "2026-02-14T19:58:19Z",
"repoId": 1108837393,
"pullRequestNo": 1845
},
{
"name": "Decrabbityyy",
"id": 99632363,
"comment_id": 3904649522,
"created_at": "2026-02-15T15:07:11Z",
"repoId": 1108837393,
"pullRequestNo": 1864
},
{
"name": "dankochetov",
"id": 33990502,
"comment_id": 3905398332,
"created_at": "2026-02-15T23:17:05Z",
"repoId": 1108837393,
"pullRequestNo": 1870
}
]
}

View File

@@ -171,7 +171,6 @@ export async function createBuiltinAgents(
availableAgents,
availableSkills,
mergedCategories,
directory,
userCategories: categories,
})
if (atlasConfig) {

View File

@@ -2,7 +2,6 @@ import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentOverrideConfig } from "../types"
import type { CategoryConfig } from "../../config/schema"
import { deepMerge, migrateAgentConfig } from "../../shared"
import { resolvePromptAppend } from "./resolve-file-uri"
/**
* Expands a category reference from an agent override into concrete config properties.
@@ -29,23 +28,19 @@ export function applyCategoryOverride(
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
if (categoryConfig.prompt_append && typeof result.prompt === "string") {
result.prompt = result.prompt + "\n" + resolvePromptAppend(categoryConfig.prompt_append)
result.prompt = result.prompt + "\n" + categoryConfig.prompt_append
}
return result as AgentConfig
}
export function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig,
directory?: string
): AgentConfig {
export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig {
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append, directory)
merged.prompt = merged.prompt + "\n" + prompt_append
}
return merged
@@ -54,8 +49,7 @@ export function mergeAgentConfig(
export function applyOverrides(
config: AgentConfig,
override: AgentOverrideConfig | undefined,
mergedCategories: Record<string, CategoryConfig>,
directory?: string
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
let result = config
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
@@ -64,7 +58,7 @@ export function applyOverrides(
}
if (override) {
result = mergeAgentConfig(result, override, directory)
result = mergeAgentConfig(result, override)
}
return result

View File

@@ -16,7 +16,6 @@ export function maybeCreateAtlasConfig(input: {
availableAgents: AvailableAgent[]
availableSkills: AvailableSkill[]
mergedCategories: Record<string, CategoryConfig>
directory?: string
userCategories?: CategoriesConfig
useTaskSystem?: boolean
}): AgentConfig | undefined {
@@ -29,7 +28,6 @@ export function maybeCreateAtlasConfig(input: {
availableAgents,
availableSkills,
mergedCategories,
directory,
userCategories,
} = input
@@ -60,7 +58,7 @@ export function maybeCreateAtlasConfig(input: {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories, directory)
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
return orchestratorConfig
}

View File

@@ -84,7 +84,7 @@ export function collectPendingBuiltinAgents(input: {
config = applyEnvironmentContext(config, directory)
}
config = applyOverrides(config, override, mergedCategories, directory)
config = applyOverrides(config, override, mergedCategories)
// Store for later - will be added after sisyphus and hephaestus
pendingAgentConfigs.set(name, config)

View File

@@ -85,7 +85,7 @@ export function maybeCreateHephaestusConfig(input: {
}
if (hephaestusOverride) {
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride, directory)
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
}
return hephaestusConfig
}

View File

@@ -1,109 +0,0 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { resolvePromptAppend } from "./resolve-file-uri"
describe("resolvePromptAppend", () => {
const fixtureRoot = join(tmpdir(), `resolve-file-uri-${Date.now()}`)
const configDir = join(fixtureRoot, "config")
const homeFixtureDir = join(homedir(), `.resolve-file-uri-home-${Date.now()}`)
const absoluteFilePath = join(fixtureRoot, "absolute.txt")
const relativeFilePath = join(configDir, "relative.txt")
const spacedFilePath = join(fixtureRoot, "with space.txt")
const homeFilePath = join(homeFixtureDir, "home.txt")
beforeAll(() => {
mkdirSync(fixtureRoot, { recursive: true })
mkdirSync(configDir, { recursive: true })
mkdirSync(homeFixtureDir, { recursive: true })
writeFileSync(absoluteFilePath, "absolute-content", "utf8")
writeFileSync(relativeFilePath, "relative-content", "utf8")
writeFileSync(spacedFilePath, "encoded-content", "utf8")
writeFileSync(homeFilePath, "home-content", "utf8")
})
afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true })
rmSync(homeFixtureDir, { recursive: true, force: true })
})
test("returns non-file URI strings unchanged", () => {
//#given
const input = "append this text"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe(input)
})
test("resolves absolute file URI to file contents", () => {
//#given
const input = `file://${absoluteFilePath}`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("absolute-content")
})
test("resolves relative file URI using configDir", () => {
//#given
const input = "file://./relative.txt"
//#when
const resolved = resolvePromptAppend(input, configDir)
//#then
expect(resolved).toBe("relative-content")
})
test("resolves home directory URI path", () => {
//#given
const input = `file://~/${homeFixtureDir.split("/").pop()}/home.txt`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("home-content")
})
test("resolves percent-encoded URI path", () => {
//#given
const input = `file://${encodeURIComponent(spacedFilePath)}`
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toBe("encoded-content")
})
test("returns warning for malformed percent-encoding", () => {
//#given
const input = "file://%E0%A4%A"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toContain("[WARNING: Malformed file URI")
})
test("returns warning when file does not exist", () => {
//#given
const input = "file:///path/does/not/exist.txt"
//#when
const resolved = resolvePromptAppend(input)
//#then
expect(resolved).toContain("[WARNING: Could not resolve file URI")
})
})

View File

@@ -1,30 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { isAbsolute, resolve } from "node:path"
export function resolvePromptAppend(promptAppend: string, configDir?: string): string {
if (!promptAppend.startsWith("file://")) return promptAppend
const encoded = promptAppend.slice(7)
let filePath: string
try {
const decoded = decodeURIComponent(encoded)
const expanded = decoded.startsWith("~/") ? decoded.replace(/^~\//, `${homedir()}/`) : decoded
filePath = isAbsolute(expanded)
? expanded
: resolve(configDir ?? process.cwd(), expanded)
} catch {
return `[WARNING: Malformed file URI (invalid percent-encoding): ${promptAppend}]`
}
if (!existsSync(filePath)) {
return `[WARNING: Could not resolve file URI: ${promptAppend}]`
}
try {
return readFileSync(filePath, "utf8")
} catch {
return `[WARNING: Could not read file: ${promptAppend}]`
}
}

View File

@@ -77,7 +77,7 @@ export function maybeCreateSisyphusConfig(input: {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories, directory)
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
return sisyphusConfig

View File

@@ -1,11 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode } from "./types";
import type {
AvailableAgent,
AvailableTool,
AvailableSkill,
AvailableCategory,
} from "./dynamic-agent-prompt-builder";
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
buildKeyTriggersSection,
buildToolSelectionTable,
@@ -17,9 +12,9 @@ import {
buildHardBlocksSection,
buildAntiPatternsSection,
categorizeTools,
} from "./dynamic-agent-prompt-builder";
} from "./dynamic-agent-prompt-builder"
const MODE: AgentMode = "primary";
const MODE: AgentMode = "primary"
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
@@ -57,7 +52,7 @@ function buildTodoDisciplineSection(useTaskSystem: boolean): string {
| Proceeding without \`in_progress\` | No indication of current work |
| Finishing without completing tasks | Task appears incomplete |
**NO TASKS ON MULTI-STEP WORK = INCOMPLETE WORK.**`;
**NO TASKS ON MULTI-STEP WORK = INCOMPLETE WORK.**`
}
return `## Todo Discipline (NON-NEGOTIABLE)
@@ -94,7 +89,7 @@ function buildTodoDisciplineSection(useTaskSystem: boolean): string {
| Proceeding without \`in_progress\` | No indication of current work |
| Finishing without completing todos | Task appears incomplete |
**NO TODOS ON MULTI-STEP WORK = INCOMPLETE WORK.**`;
**NO TODOS ON MULTI-STEP WORK = INCOMPLETE WORK.**`
}
/**
@@ -116,25 +111,18 @@ function buildHephaestusPrompt(
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = [],
availableCategories: AvailableCategory[] = [],
useTaskSystem = false,
useTaskSystem = false
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills);
const toolSelection = buildToolSelectionTable(
availableAgents,
availableTools,
availableSkills,
);
const exploreSection = buildExploreSection(availableAgents);
const librarianSection = buildLibrarianSection(availableAgents);
const categorySkillsGuide = buildCategorySkillsDelegationGuide(
availableCategories,
availableSkills,
);
const delegationTable = buildDelegationTable(availableAgents);
const oracleSection = buildOracleSection(availableAgents);
const hardBlocks = buildHardBlocksSection();
const antiPatterns = buildAntiPatternsSection();
const todoDiscipline = buildTodoDisciplineSection(useTaskSystem);
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
const exploreSection = buildExploreSection(availableAgents)
const librarianSection = buildLibrarianSection(availableAgents)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, availableSkills)
const delegationTable = buildDelegationTable(availableAgents)
const oracleSection = buildOracleSection(availableAgents)
const hardBlocks = buildHardBlocksSection()
const antiPatterns = buildAntiPatternsSection()
const todoDiscipline = buildTodoDisciplineSection(useTaskSystem)
return `You are Hephaestus, an autonomous deep worker for software engineering.
@@ -238,7 +226,6 @@ Agent: *runs gh pr list, gh pr view, searches recent commits*
### Step 3: Validate Before Acting
**Delegation Check (MANDATORY before acting directly):**
0. Find relevant skills that you can load, and load them IMMEDIATELY.
1. Is there a specialized agent that perfectly matches this request?
2. If not, is there a \`task\` category that best describes this task? What skills are available to equip the agent with?
- MUST FIND skills to use: \`task(load_skills=[{skill1}, ...])\`
@@ -424,13 +411,9 @@ Every \`task()\` output includes a session_id. **USE IT.**
**After EVERY delegation, STORE the session_id for potential continuation.**
${
oracleSection
? `
${oracleSection ? `
${oracleSection}
`
: ""
}
` : ""}
## Role & Agency (CRITICAL - READ CAREFULLY)
@@ -608,7 +591,7 @@ When working on long sessions or complex multi-file tasks:
## Soft Guidelines
- Prefer existing libraries over new dependencies
- Prefer small, focused changes over large refactors`;
- Prefer small, focused changes over large refactors`
}
export function createHephaestusAgent(
@@ -617,20 +600,14 @@ export function createHephaestusAgent(
availableToolNames?: string[],
availableSkills?: AvailableSkill[],
availableCategories?: AvailableCategory[],
useTaskSystem = false,
useTaskSystem = false
): AgentConfig {
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? [];
const categories = availableCategories ?? [];
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
const skills = availableSkills ?? []
const categories = availableCategories ?? []
const prompt = availableAgents
? buildHephaestusPrompt(
availableAgents,
tools,
skills,
categories,
useTaskSystem,
)
: buildHephaestusPrompt([], tools, skills, categories, useTaskSystem);
? buildHephaestusPrompt(availableAgents, tools, skills, categories, useTaskSystem)
: buildHephaestusPrompt([], tools, skills, categories, useTaskSystem)
return {
description:
@@ -640,11 +617,8 @@ export function createHephaestusAgent(
maxTokens: 32000,
prompt,
color: "#D97706", // Forged Amber - Golden heated metal, divine craftsman
permission: {
question: "allow",
call_omo_agent: "deny",
} as AgentConfig["permission"],
permission: { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"],
reasoningEffort: "medium",
};
}
}
createHephaestusAgent.mode = MODE;
createHephaestusAgent.mode = MODE

View File

@@ -66,7 +66,7 @@ describe("PROMETHEUS_SYSTEM_PROMPT zero human intervention", () => {
expect(lowerPrompt).toContain("preconditions")
expect(lowerPrompt).toContain("failure indicators")
expect(lowerPrompt).toContain("evidence")
expect(prompt).toMatch(/negative/i)
expect(lowerPrompt).toMatch(/negative scenario/)
})
test("should require QA scenario adequacy in self-review checklist", () => {

View File

@@ -129,21 +129,7 @@ Your ONLY valid output locations are \`.sisyphus/plans/*.md\` and \`.sisyphus/dr
Example: \`.sisyphus/plans/auth-refactor.md\`
### 5. MAXIMUM PARALLELISM PRINCIPLE (NON-NEGOTIABLE)
Your plans MUST maximize parallel execution. This is a core planning quality metric.
**Granularity Rule**: One task = one module/concern = 1-3 files.
If a task touches 4+ files or 2+ unrelated concerns, SPLIT IT.
**Parallelism Target**: Aim for 5-8 tasks per wave.
If any wave has fewer than 3 tasks (except the final integration), you under-split.
**Dependency Minimization**: Structure tasks so shared dependencies
(types, interfaces, configs) are extracted as early Wave-1 tasks,
unblocking maximum parallelism in subsequent waves.
### 6. SINGLE PLAN MANDATE (CRITICAL)
### 5. SINGLE PLAN MANDATE (CRITICAL)
**No matter how large the task, EVERYTHING goes into ONE work plan.**
**NEVER:**
@@ -166,7 +152,7 @@ unblocking maximum parallelism in subsequent waves.
**The plan can have 50+ TODOs. That's OK. ONE PLAN.**
### 6.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
### 5.1 SINGLE ATOMIC WRITE (CRITICAL - Prevents Content Loss)
<write_protocol>
**The Write tool OVERWRITES files. It does NOT append.**
@@ -202,7 +188,7 @@ unblocking maximum parallelism in subsequent waves.
- [ ] File already exists with my content? → Use Edit to append, NOT Write
</write_protocol>
### 7. DRAFT AS WORKING MEMORY (MANDATORY)
### 6. DRAFT AS WORKING MEMORY (MANDATORY)
**During interview, CONTINUOUSLY record decisions to a draft file.**
**Draft Location**: \`.sisyphus/drafts/{name}.md\`

View File

@@ -70,25 +70,108 @@ Generate plan to: \`.sisyphus/plans/{name}.md\`
## Verification Strategy (MANDATORY)
> **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions.
> Acceptance criteria requiring "user manually tests/confirms" are FORBIDDEN.
> **UNIVERSAL RULE: ZERO HUMAN INTERVENTION**
>
> ALL tasks in this plan MUST be verifiable WITHOUT any human action.
> This is NOT conditional — it applies to EVERY task, regardless of test strategy.
>
> **FORBIDDEN** — acceptance criteria that require:
> - "User manually tests..." / "사용자가 직접 테스트..."
> - "User visually confirms..." / "사용자가 눈으로 확인..."
> - "User interacts with..." / "사용자가 직접 조작..."
> - "Ask user to verify..." / "사용자에게 확인 요청..."
> - ANY step where a human must perform an action
>
> **ALL verification is executed by the agent** using tools (Playwright, interactive_bash, curl, etc.). No exceptions.
### Test Decision
- **Infrastructure exists**: [YES/NO]
- **Automated tests**: [TDD / Tests-after / None]
- **Framework**: [bun test / vitest / jest / pytest / none]
- **If TDD**: Each task follows RED (failing test) → GREEN (minimal impl) → REFACTOR
### QA Policy
Every task MUST include agent-executed QA scenarios (see TODO template below).
Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
### If TDD Enabled
| Deliverable Type | Verification Tool | Method |
|------------------|-------------------|--------|
| Frontend/UI | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
| TUI/CLI | interactive_bash (tmux) | Run command, send keystrokes, validate output |
| API/Backend | Bash (curl) | Send requests, assert status + response fields |
| Library/Module | Bash (bun/node REPL) | Import, call functions, compare output |
Each TODO follows RED-GREEN-REFACTOR:
**Task Structure:**
1. **RED**: Write failing test first
- Test file: \`[path].test.ts\`
- Test command: \`bun test [file]\`
- Expected: FAIL (test exists, implementation doesn't)
2. **GREEN**: Implement minimum code to pass
- Command: \`bun test [file]\`
- Expected: PASS
3. **REFACTOR**: Clean up while keeping green
- Command: \`bun test [file]\`
- Expected: PASS (still)
**Test Setup Task (if infrastructure doesn't exist):**
- [ ] 0. Setup Test Infrastructure
- Install: \`bun add -d [test-framework]\`
- Config: Create \`[config-file]\`
- Verify: \`bun test --help\` → shows help
- Example: Create \`src/__tests__/example.test.ts\`
- Verify: \`bun test\` → 1 test passes
### Agent-Executed QA Scenarios (MANDATORY — ALL tasks)
> Whether TDD is enabled or not, EVERY task MUST include Agent-Executed QA Scenarios.
> - **With TDD**: QA scenarios complement unit tests at integration/E2E level
> - **Without TDD**: QA scenarios are the PRIMARY verification method
>
> These describe how the executing agent DIRECTLY verifies the deliverable
> by running it — opening browsers, executing commands, sending API requests.
> The agent performs what a human tester would do, but automated via tools.
**Verification Tool by Deliverable Type:**
| Type | Tool | How Agent Verifies |
|------|------|-------------------|
| **Frontend/UI** | Playwright (playwright skill) | Navigate, interact, assert DOM, screenshot |
| **TUI/CLI** | interactive_bash (tmux) | Run command, send keystrokes, validate output |
| **API/Backend** | Bash (curl/httpie) | Send requests, parse responses, assert fields |
| **Library/Module** | Bash (bun/node REPL) | Import, call functions, compare output |
| **Config/Infra** | Bash (shell commands) | Apply config, run state checks, validate |
**Each Scenario MUST Follow This Format:**
\`\`\`
Scenario: [Descriptive name — what user action/flow is being verified]
Tool: [Playwright / interactive_bash / Bash]
Preconditions: [What must be true before this scenario runs]
Steps:
1. [Exact action with specific selector/command/endpoint]
2. [Next action with expected intermediate state]
3. [Assertion with exact expected value]
Expected Result: [Concrete, observable outcome]
Failure Indicators: [What would indicate failure]
Evidence: [Screenshot path / output capture / response body path]
\`\`\`
**Scenario Detail Requirements:**
- **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
- **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
- **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
- **Timing**: Include wait conditions where relevant (\`Wait for .dashboard (timeout: 10s)\`)
- **Negative Scenarios**: At least ONE failure/error scenario per feature
- **Evidence Paths**: Specific file paths (\`.sisyphus/evidence/task-N-scenario-name.png\`)
**Anti-patterns (NEVER write scenarios like this):**
- ❌ "Verify the login page works correctly"
- ❌ "Check that the API returns the right data"
- ❌ "Test the form validation"
- ❌ "User opens browser and confirms..."
**Write scenarios like this instead:**
- ✅ \`Navigate to /login → Fill input[name="email"] with "test@example.com" → Fill input[name="password"] with "Pass123!" → Click button[type="submit"] → Wait for /dashboard → Assert h1 contains "Welcome"\`
- ✅ \`POST /api/users {"name":"Test","email":"new@test.com"} → Assert status 201 → Assert response.id is UUID → GET /api/users/{id} → Assert name equals "Test"\`
- ✅ \`Run ./cli --config test.yaml → Wait for "Loaded" in stdout → Send "q" → Assert exit code 0 → Assert stdout contains "Goodbye"\`
**Evidence Requirements:**
- Screenshots: \`.sisyphus/evidence/\` for all UI verifications
- Terminal output: Captured for CLI/TUI verifications
- Response bodies: Saved for API verifications
- All evidence referenced by specific file path in acceptance criteria
---
@@ -98,82 +181,49 @@ Evidence saved to \`.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}\`.
> Maximize throughput by grouping independent tasks into parallel waves.
> Each wave completes before the next begins.
> Target: 5-8 tasks per wave. Fewer than 3 per wave (except final) = under-splitting.
\`\`\`
Wave 1 (Start Immediately — foundation + scaffolding):
├── Task 1: Project scaffolding + config [quick]
── Task 2: Design system tokens [quick]
├── Task 3: Type definitions [quick]
├── Task 4: Schema definitions [quick]
├── Task 5: Storage interface + in-memory impl [quick]
├── Task 6: Auth middleware [quick]
└── Task 7: Client module [quick]
Wave 1 (Start Immediately):
├── Task 1: [no dependencies]
── Task 5: [no dependencies]
Wave 2 (After Wave 1 — core modules, MAX PARALLEL):
├── Task 8: Core business logic (depends: 3, 5, 7) [deep]
├── Task 9: API endpoints (depends: 4, 5) [unspecified-high]
── Task 10: Secondary storage impl (depends: 5) [unspecified-high]
├── Task 11: Retry/fallback logic (depends: 8) [deep]
├── Task 12: UI layout + navigation (depends: 2) [visual-engineering]
├── Task 13: API client + hooks (depends: 4) [quick]
└── Task 14: Telemetry middleware (depends: 5, 10) [unspecified-high]
Wave 2 (After Wave 1):
├── Task 2: [depends: 1]
├── Task 3: [depends: 1]
── Task 6: [depends: 5]
Wave 3 (After Wave 2 — integration + UI):
── Task 15: Main route combining modules (depends: 6, 11, 14) [deep]
├── Task 16: UI data visualization (depends: 12, 13) [visual-engineering]
├── Task 17: Deployment config A (depends: 15) [quick]
├── Task 18: Deployment config B (depends: 15) [quick]
├── Task 19: Deployment config C (depends: 15) [quick]
└── Task 20: UI request log + build (depends: 16) [visual-engineering]
Wave 3 (After Wave 2):
── Task 4: [depends: 2, 3]
Wave 4 (After Wave 3 — verification):
├── Task 21: Integration tests (depends: 15) [deep]
├── Task 22: UI QA - Playwright (depends: 20) [unspecified-high]
├── Task 23: E2E QA (depends: 21) [deep]
└── Task 24: Git cleanup + tagging (depends: 21) [git]
Wave FINAL (After ALL tasks — independent review, 4 parallel):
├── Task F1: Plan compliance audit (oracle)
├── Task F2: Code quality review (unspecified-high)
├── Task F3: Real manual QA (unspecified-high)
└── Task F4: Scope fidelity check (deep)
Critical Path: Task 1 → Task 5 → Task 8 → Task 11 → Task 15 → Task 21 → F1-F4
Parallel Speedup: ~70% faster than sequential
Max Concurrent: 7 (Waves 1 & 2)
Critical Path: Task 1 → Task 2 → Task 4
Parallel Speedup: ~40% faster than sequential
\`\`\`
### Dependency Matrix (abbreviated — show ALL tasks in your generated plan)
### Dependency Matrix
| Task | Depends On | Blocks | Wave |
|------|------------|--------|------|
| 1-7 | — | 8-14 | 1 |
| 8 | 3, 5, 7 | 11, 15 | 2 |
| 11 | 8 | 15 | 2 |
| 14 | 5, 10 | 15 | 2 |
| 15 | 6, 11, 14 | 17-19, 21 | 3 |
| 21 | 15 | 23, 24 | 4 |
> This is abbreviated for reference. YOUR generated plan must include the FULL matrix for ALL tasks.
| Task | Depends On | Blocks | Can Parallelize With |
|------|------------|--------|---------------------|
| 1 | None | 2, 3 | 5 |
| 2 | 1 | 4 | 3, 6 |
| 3 | 1 | 4 | 2, 6 |
| 4 | 2, 3 | None | None (final) |
| 5 | None | 6 | 1 |
| 6 | 5 | None | 2, 3 |
### Agent Dispatch Summary
| Wave | # Parallel | Tasks → Agent Category |
|------|------------|----------------------|
| 1 | **7** | T1-T4 → \`quick\`, T5 \`quick\`, T6 → \`quick\`, T7 → \`quick\` |
| 2 | **7** | T8 → \`deep\`, T9 → \`unspecified-high\`, T10 → \`unspecified-high\`, T11 → \`deep\`, T12 → \`visual-engineering\`, T13 → \`quick\`, T14 → \`unspecified-high\` |
| 3 | **6** | T15 → \`deep\`, T16 → \`visual-engineering\`, T17-T19 → \`quick\`, T20 → \`visual-engineering\` |
| 4 | **4** | T21 → \`deep\`, T22 → \`unspecified-high\`, T23 → \`deep\`, T24 → \`git\` |
| FINAL | **4** | F1 → \`oracle\`, F2 → \`unspecified-high\`, F3 → \`unspecified-high\`, F4 → \`deep\` |
| Wave | Tasks | Recommended Agents |
|------|-------|-------------------|
| 1 | 1, 5 | task(category="...", load_skills=[...], run_in_background=false) |
| 2 | 2, 3, 6 | dispatch parallel after Wave 1 completes |
| 3 | 4 | final integration task |
---
## TODOs
> Implementation + Test = ONE Task. Never separate.
> EVERY task MUST have: Recommended Agent Profile + Parallelization info + QA Scenarios.
> **A task WITHOUT QA Scenarios is INCOMPLETE. No exceptions.**
> EVERY task MUST have: Recommended Agent Profile + Parallelization info.
- [ ] 1. [Task Title]
@@ -207,15 +257,22 @@ Max Concurrent: 7 (Waves 1 & 2)
**Pattern References** (existing code to follow):
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling)
- \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration)
**API/Type References** (contracts to implement against):
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
- \`src/api/schema.ts:createUserSchema\` - Request validation schema
**Test References** (testing patterns to follow):
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns
**Documentation References** (specs and requirements):
- \`docs/api-spec.md#authentication\` - API contract details
- \`ARCHITECTURE.md:Database Layer\` - Database access patterns
**External References** (libraries and frameworks):
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax
- Example repo: \`github.com/example/project/src/auth\` - Reference implementation
**WHY Each Reference Matters** (explain the relevance):
- Don't just list files - explain what pattern/information the executor should extract
@@ -226,60 +283,113 @@ Max Concurrent: 7 (Waves 1 & 2)
> **AGENT-EXECUTABLE VERIFICATION ONLY** — No human action permitted.
> Every criterion MUST be verifiable by running a command or using a tool.
> REPLACE all placeholders with actual values from task context.
**If TDD (tests enabled):**
- [ ] Test file created: src/auth/login.test.ts
- [ ] Test covers: successful login returns JWT token
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
**QA Scenarios (MANDATORY — task is INCOMPLETE without these):**
**Agent-Executed QA Scenarios (MANDATORY — per-scenario, ultra-detailed):**
> **This is NOT optional. A task without QA scenarios WILL BE REJECTED.**
>
> Write scenario tests that verify the ACTUAL BEHAVIOR of what you built.
> Minimum: 1 happy path + 1 failure/edge case per task.
> Each scenario = exact tool + exact steps + exact assertions + evidence path.
>
> **The executing agent MUST run these scenarios after implementation.**
> **The orchestrator WILL verify evidence files exist before marking task complete.**
> Write MULTIPLE named scenarios per task: happy path AND failure cases.
> Each scenario = exact tool + steps with real selectors/data + evidence path.
**Example — Frontend/UI (Playwright):**
\\\`\\\`\\\`
Scenario: [Happy path — what SHOULD work]
Tool: [Playwright / interactive_bash / Bash (curl)]
Preconditions: [Exact setup state]
Scenario: Successful login redirects to dashboard
Tool: Playwright (playwright skill)
Preconditions: Dev server running on localhost:3000, test user exists
Steps:
1. [Exact action — specific command/selector/endpoint, no vagueness]
2. [Next action — with expected intermediate state]
3. [Assertion — exact expected value, not "verify it works"]
Expected Result: [Concrete, observable, binary pass/fail]
Failure Indicators: [What specifically would mean this failed]
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}.{ext}
1. Navigate to: http://localhost:3000/login
2. Wait for: input[name="email"] visible (timeout: 5s)
3. Fill: input[name="email"] → "test@example.com"
4. Fill: input[name="password"] → "ValidPass123!"
5. Click: button[type="submit"]
6. Wait for: navigation to /dashboard (timeout: 10s)
7. Assert: h1 text contains "Welcome back"
8. Assert: cookie "session_token" exists
9. Screenshot: .sisyphus/evidence/task-1-login-success.png
Expected Result: Dashboard loads with welcome message
Evidence: .sisyphus/evidence/task-1-login-success.png
Scenario: [Failure/edge case — what SHOULD fail gracefully]
Tool: [same format]
Preconditions: [Invalid input / missing dependency / error state]
Scenario: Login fails with invalid credentials
Tool: Playwright (playwright skill)
Preconditions: Dev server running, no valid user with these credentials
Steps:
1. [Trigger the error condition]
2. [Assert error is handled correctly]
Expected Result: [Graceful failure with correct error message/code]
Evidence: .sisyphus/evidence/task-{N}-{scenario-slug}-error.{ext}
1. Navigate to: http://localhost:3000/login
2. Fill: input[name="email"] → "wrong@example.com"
3. Fill: input[name="password"] → "WrongPass"
4. Click: button[type="submit"]
5. Wait for: .error-message visible (timeout: 5s)
6. Assert: .error-message text contains "Invalid credentials"
7. Assert: URL is still /login (no redirect)
8. Screenshot: .sisyphus/evidence/task-1-login-failure.png
Expected Result: Error message shown, stays on login page
Evidence: .sisyphus/evidence/task-1-login-failure.png
\\\`\\\`\\\`
> **Specificity requirements — every scenario MUST use:**
> - **Selectors**: Specific CSS selectors (\`.login-button\`, not "the login button")
> - **Data**: Concrete test data (\`"test@example.com"\`, not \`"[email]"\`)
> - **Assertions**: Exact values (\`text contains "Welcome back"\`, not "verify it works")
> - **Timing**: Wait conditions where relevant (\`timeout: 10s\`)
> - **Negative**: At least ONE failure/error scenario per task
>
> **Anti-patterns (your scenario is INVALID if it looks like this):**
> - ❌ "Verify it works correctly" — HOW? What does "correctly" mean?
> - ❌ "Check the API returns data" — WHAT data? What fields? What values?
> - ❌ "Test the component renders" — WHERE? What selector? What content?
> - ❌ Any scenario without an evidence path
**Example — API/Backend (curl):**
\\\`\\\`\\\`
Scenario: Create user returns 201 with UUID
Tool: Bash (curl)
Preconditions: Server running on localhost:8080
Steps:
1. curl -s -w "\\n%{http_code}" -X POST http://localhost:8080/api/users \\
-H "Content-Type: application/json" \\
-d '{"email":"new@test.com","name":"Test User"}'
2. Assert: HTTP status is 201
3. Assert: response.id matches UUID format
4. GET /api/users/{returned-id} → Assert name equals "Test User"
Expected Result: User created and retrievable
Evidence: Response bodies captured
Scenario: Duplicate email returns 409
Tool: Bash (curl)
Preconditions: User with email "new@test.com" already exists
Steps:
1. Repeat POST with same email
2. Assert: HTTP status is 409
3. Assert: response.error contains "already exists"
Expected Result: Conflict error returned
Evidence: Response body captured
\\\`\\\`\\\`
**Example — TUI/CLI (interactive_bash):**
\\\`\\\`\\\`
Scenario: CLI loads config and displays menu
Tool: interactive_bash (tmux)
Preconditions: Binary built, test config at ./test.yaml
Steps:
1. tmux new-session: ./my-cli --config test.yaml
2. Wait for: "Configuration loaded" in output (timeout: 5s)
3. Assert: Menu items visible ("1. Create", "2. List", "3. Exit")
4. Send keys: "3" then Enter
5. Assert: "Goodbye" in output
6. Assert: Process exited with code 0
Expected Result: CLI starts, shows menu, exits cleanly
Evidence: Terminal output captured
Scenario: CLI handles missing config gracefully
Tool: interactive_bash (tmux)
Preconditions: No config file at ./nonexistent.yaml
Steps:
1. tmux new-session: ./my-cli --config nonexistent.yaml
2. Wait for: output (timeout: 3s)
3. Assert: stderr contains "Config file not found"
4. Assert: Process exited with code 1
Expected Result: Meaningful error, non-zero exit
Evidence: Error output captured
\\\`\\\`\\\`
**Evidence to Capture:**
- [ ] Screenshots in .sisyphus/evidence/ for UI scenarios
- [ ] Terminal output for CLI/TUI scenarios
- [ ] Response bodies for API scenarios
- [ ] Each evidence file named: task-{N}-{scenario-slug}.{ext}
- [ ] Screenshots for UI, terminal output for CLI, response bodies for API
**Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\`
@@ -288,28 +398,6 @@ Max Concurrent: 7 (Waves 1 & 2)
---
## Final Verification Wave (MANDATORY — after ALL implementation tasks)
> 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run.
- [ ] F1. **Plan Compliance Audit** — \`oracle\`
Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, curl endpoint, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan.
Output: \`Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT\`
- [ ] F2. **Code Quality Review** — \`unspecified-high\`
Run \`tsc --noEmit\` + linter + \`bun test\`. Review all changed files for: \`as any\`/\`@ts-ignore\`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names (data/result/item/temp).
Output: \`Build [PASS/FAIL] | Lint [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT\`
- [ ] F3. **Real Manual QA** — \`unspecified-high\` (+ \`playwright\` skill if UI)
Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration (features working together, not isolation). Test edge cases: empty state, invalid input, rapid actions. Save to \`.sisyphus/evidence/final-qa/\`.
Output: \`Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT\`
- [ ] F4. **Scope Fidelity Check** — \`deep\`
For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Detect cross-task contamination: Task N touching Task M's files. Flag unaccounted changes.
Output: \`Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT\`
---
## Commit Strategy
| After Task | Message | Files | Verification |

View File

@@ -7,8 +7,6 @@
* - Extended reasoning context for complex tasks
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildDefaultSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
@@ -42,7 +40,7 @@ Task NOT complete without:
</Style>`
if (!promptAppend) return prompt
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
return prompt + "\n\n" + promptAppend
}
function buildConstraintsSection(useTaskSystem: boolean): string {

View File

@@ -16,8 +16,6 @@
* - Explicit decision criteria needed (model won't infer)
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
export function buildGptSisyphusJuniorPrompt(
useTaskSystem: boolean,
promptAppend?: string
@@ -87,7 +85,7 @@ Task NOT complete without evidence:
</style_spec>`
if (!promptAppend) return prompt
return prompt + "\n\n" + resolvePromptAppend(promptAppend)
return prompt + "\n\n" + promptAppend
}
function buildGptBlockedActionsSection(useTaskSystem: boolean): string {

View File

@@ -1,20 +1,15 @@
import type { AgentConfig } from "@opencode-ai/sdk";
import type { AgentMode, AgentPromptMetadata } from "./types";
import { isGptModel } from "./types";
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
const MODE: AgentMode = "primary";
const MODE: AgentMode = "primary"
export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "EXPENSIVE",
promptAlias: "Sisyphus",
triggers: [],
};
import type {
AvailableAgent,
AvailableTool,
AvailableSkill,
AvailableCategory,
} from "./dynamic-agent-prompt-builder";
}
import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
buildKeyTriggersSection,
buildToolSelectionTable,
@@ -26,7 +21,7 @@ import {
buildHardBlocksSection,
buildAntiPatternsSection,
categorizeTools,
} from "./dynamic-agent-prompt-builder";
} from "./dynamic-agent-prompt-builder"
function buildTaskManagementSection(useTaskSystem: boolean): string {
if (useTaskSystem) {
@@ -85,7 +80,7 @@ I want to make sure I understand correctly.
Should I proceed with [recommendation], or would you prefer differently?
\`\`\`
</Task_Management>`;
</Task_Management>`
}
return `<Task_Management>
@@ -143,7 +138,7 @@ I want to make sure I understand correctly.
Should I proceed with [recommendation], or would you prefer differently?
\`\`\`
</Task_Management>`;
</Task_Management>`
}
function buildDynamicSisyphusPrompt(
@@ -151,28 +146,21 @@ function buildDynamicSisyphusPrompt(
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = [],
availableCategories: AvailableCategory[] = [],
useTaskSystem = false,
useTaskSystem = false
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills);
const toolSelection = buildToolSelectionTable(
availableAgents,
availableTools,
availableSkills,
);
const exploreSection = buildExploreSection(availableAgents);
const librarianSection = buildLibrarianSection(availableAgents);
const categorySkillsGuide = buildCategorySkillsDelegationGuide(
availableCategories,
availableSkills,
);
const delegationTable = buildDelegationTable(availableAgents);
const oracleSection = buildOracleSection(availableAgents);
const hardBlocks = buildHardBlocksSection();
const antiPatterns = buildAntiPatternsSection();
const taskManagementSection = buildTaskManagementSection(useTaskSystem);
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
const exploreSection = buildExploreSection(availableAgents)
const librarianSection = buildLibrarianSection(availableAgents)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, availableSkills)
const delegationTable = buildDelegationTable(availableAgents)
const oracleSection = buildOracleSection(availableAgents)
const hardBlocks = buildHardBlocksSection()
const antiPatterns = buildAntiPatternsSection()
const taskManagementSection = buildTaskManagementSection(useTaskSystem)
const todoHookNote = useTaskSystem
? "YOUR TASK CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TASK CONTINUATION])"
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])";
: "YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION])"
return `<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
@@ -327,7 +315,6 @@ STOP searching when:
## Phase 2B - Implementation
### Pre-Implementation:
0. Find relevant skills that you can load, and load them IMMEDIATELY.
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
2. Mark current task \`in_progress\` before starting
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
@@ -510,7 +497,7 @@ ${antiPatterns}
- Prefer small, focused changes over large refactors
- When uncertain about scope, ask
</Constraints>
`;
`
}
export function createSisyphusAgent(
@@ -519,25 +506,16 @@ export function createSisyphusAgent(
availableToolNames?: string[],
availableSkills?: AvailableSkill[],
availableCategories?: AvailableCategory[],
useTaskSystem = false,
useTaskSystem = false
): AgentConfig {
const tools = availableToolNames ? categorizeTools(availableToolNames) : [];
const skills = availableSkills ?? [];
const categories = availableCategories ?? [];
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
const skills = availableSkills ?? []
const categories = availableCategories ?? []
const prompt = availableAgents
? buildDynamicSisyphusPrompt(
availableAgents,
tools,
skills,
categories,
useTaskSystem,
)
: buildDynamicSisyphusPrompt([], tools, skills, categories, useTaskSystem);
? buildDynamicSisyphusPrompt(availableAgents, tools, skills, categories, useTaskSystem)
: buildDynamicSisyphusPrompt([], tools, skills, categories, useTaskSystem)
const permission = {
question: "allow",
call_omo_agent: "deny",
} as AgentConfig["permission"];
const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"]
const base = {
description:
"Powerful AI orchestrator. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs. (Sisyphus - OhMyOpenCode)",
@@ -547,12 +525,12 @@ export function createSisyphusAgent(
prompt,
color: "#00CED1",
permission,
};
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" };
}
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } };
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium" }
}
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
}
createSisyphusAgent.mode = MODE;
createSisyphusAgent.mode = MODE

View File

@@ -1,49 +0,0 @@
import { describe, test, expect } from "bun:test";
import { isGptModel } from "./types";
describe("isGptModel", () => {
test("standard openai provider models", () => {
expect(isGptModel("openai/gpt-5.2")).toBe(true);
expect(isGptModel("openai/gpt-4o")).toBe(true);
expect(isGptModel("openai/o1")).toBe(true);
expect(isGptModel("openai/o3-mini")).toBe(true);
});
test("github copilot gpt models", () => {
expect(isGptModel("github-copilot/gpt-5.2")).toBe(true);
expect(isGptModel("github-copilot/gpt-4o")).toBe(true);
});
test("litellm proxied gpt models", () => {
expect(isGptModel("litellm/gpt-5.2")).toBe(true);
expect(isGptModel("litellm/gpt-4o")).toBe(true);
expect(isGptModel("litellm/o1")).toBe(true);
expect(isGptModel("litellm/o3-mini")).toBe(true);
expect(isGptModel("litellm/o4-mini")).toBe(true);
});
test("other proxied gpt models", () => {
expect(isGptModel("ollama/gpt-4o")).toBe(true);
expect(isGptModel("custom-provider/gpt-5.2")).toBe(true);
});
test("gpt4 prefix without hyphen (legacy naming)", () => {
expect(isGptModel("litellm/gpt4o")).toBe(true);
expect(isGptModel("ollama/gpt4")).toBe(true);
});
test("claude models are not gpt", () => {
expect(isGptModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isGptModel("anthropic/claude-sonnet-4-5")).toBe(false);
expect(isGptModel("litellm/anthropic.claude-opus-4-5")).toBe(false);
});
test("gemini models are not gpt", () => {
expect(isGptModel("google/gemini-3-pro")).toBe(false);
expect(isGptModel("litellm/gemini-3-pro")).toBe(false);
});
test("opencode provider is not gpt", () => {
expect(isGptModel("opencode/claude-opus-4-6")).toBe(false);
});
});

View File

@@ -66,18 +66,8 @@ export interface AgentPromptMetadata {
keyTrigger?: string
}
function extractModelName(model: string): string {
return model.includes("/") ? model.split("/").pop() ?? model : model
}
const GPT_MODEL_PREFIXES = ["gpt-", "gpt4", "o1", "o3", "o4"]
export function isGptModel(model: string): boolean {
if (model.startsWith("openai/") || model.startsWith("github-copilot/gpt-"))
return true
const modelName = extractModelName(model).toLowerCase()
return GPT_MODEL_PREFIXES.some((prefix) => modelName.startsWith(prefix))
return model.startsWith("openai/") || model.startsWith("github-copilot/gpt-")
}
export type BuiltinAgentName =

View File

@@ -247,7 +247,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -314,7 +314,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.2",
},
},
}
@@ -372,7 +372,6 @@ exports[`generateModelConfig single native provider uses Gemini models when only
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -433,7 +432,6 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -507,7 +505,6 @@ exports[`generateModelConfig all native providers uses preferred models from fal
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -582,7 +579,6 @@ exports[`generateModelConfig all native providers uses preferred models with isM
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -656,7 +652,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -731,7 +726,6 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -805,7 +799,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -880,7 +873,6 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -935,10 +927,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -990,10 +982,10 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "zai-coding-plan/glm-4.7",
},
},
}
@@ -1064,7 +1056,6 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
},
"visual-engineering": {
"model": "opencode/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "opencode/gemini-3-flash",
@@ -1138,7 +1129,6 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -1199,7 +1189,8 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-sonnet-4-5",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
"model": "anthropic/claude-opus-4-6",
"variant": "max",
},
"writing": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1265,7 +1256,6 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1339,7 +1329,6 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
},
"visual-engineering": {
"model": "github-copilot/gemini-3-pro-preview",
"variant": "high",
},
"writing": {
"model": "github-copilot/gemini-3-flash-preview",
@@ -1413,7 +1402,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",
@@ -1488,7 +1476,6 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
},
"visual-engineering": {
"model": "google/gemini-3-pro",
"variant": "high",
},
"writing": {
"model": "google/gemini-3-flash",

View File

@@ -1,83 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
import * as configManager from "./config-manager"
import { runCliInstaller } from "./cli-installer"
import type { InstallArgs } from "./types"
describe("runCliInstaller", () => {
const mockConsoleLog = mock(() => {})
const mockConsoleError = mock(() => {})
const originalConsoleLog = console.log
const originalConsoleError = console.error
beforeEach(() => {
console.log = mockConsoleLog
console.error = mockConsoleError
mockConsoleLog.mockClear()
mockConsoleError.mockClear()
})
afterEach(() => {
console.log = originalConsoleLog
console.error = originalConsoleError
})
it("runs auth and provider setup steps when openai or copilot are enabled without gemini", async () => {
//#given
const addAuthPluginsSpy = spyOn(configManager, "addAuthPlugins").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
})
const addProviderConfigSpy = spyOn(configManager, "addProviderConfig").mockReturnValue({
success: true,
configPath: "/tmp/opencode.jsonc",
})
const restoreSpies = [
addAuthPluginsSpy,
addProviderConfigSpy,
spyOn(configManager, "detectCurrentConfig").mockReturnValue({
isInstalled: false,
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
spyOn(configManager, "addPluginToOpenCodeConfig").mockResolvedValue({
success: true,
configPath: "/tmp/opencode.jsonc",
}),
spyOn(configManager, "writeOmoConfig").mockReturnValue({
success: true,
configPath: "/tmp/oh-my-opencode.jsonc",
}),
]
const args: InstallArgs = {
tui: false,
claude: "no",
openai: "yes",
gemini: "no",
copilot: "yes",
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
}
//#when
const result = await runCliInstaller(args, "3.4.0")
//#then
expect(result).toBe(0)
expect(addAuthPluginsSpy).toHaveBeenCalledTimes(1)
expect(addProviderConfigSpy).toHaveBeenCalledTimes(1)
for (const spy of restoreSpies) {
spy.mockRestore()
}
})
})

View File

@@ -77,9 +77,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`,
)
const needsProviderSetup = config.hasGemini || config.hasOpenAI || config.hasCopilot
if (needsProviderSetup) {
if (config.hasGemini) {
printStep(step++, totalSteps, "Adding auth plugins...")
const authResult = await addAuthPlugins(config)
if (!authResult.success) {

View File

@@ -149,21 +149,29 @@ This command shows:
program
.command("doctor")
.description("Check oh-my-opencode installation health and diagnose issues")
.option("--status", "Show compact system dashboard")
.option("--verbose", "Show detailed diagnostic information")
.option("--json", "Output results in JSON format")
.option("--category <category>", "Run only specific category")
.addHelpText("after", `
Examples:
$ bunx oh-my-opencode doctor # Show problems only
$ bunx oh-my-opencode doctor --status # Compact dashboard
$ bunx oh-my-opencode doctor --verbose # Deep diagnostics
$ bunx oh-my-opencode doctor --json # JSON output
$ bunx oh-my-opencode doctor
$ bunx oh-my-opencode doctor --verbose
$ bunx oh-my-opencode doctor --json
$ bunx oh-my-opencode doctor --category authentication
Categories:
installation Check OpenCode and plugin installation
configuration Validate configuration files
authentication Check auth provider status
dependencies Check external dependencies
tools Check LSP and MCP servers
updates Check for version updates
`)
.action(async (options) => {
const mode = options.status ? "status" : options.verbose ? "verbose" : "default"
const doctorOptions: DoctorOptions = {
mode,
verbose: options.verbose ?? false,
json: options.json ?? false,
category: options.category,
}
const exitCode = await doctor(doctorOptions)
process.exit(exitCode)

View File

@@ -0,0 +1,114 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as auth from "./auth"
describe("auth check", () => {
describe("getAuthProviderInfo", () => {
it("returns anthropic as always available", () => {
// given anthropic provider
// when getting info
const info = auth.getAuthProviderInfo("anthropic")
// then should show plugin installed (builtin)
expect(info.id).toBe("anthropic")
expect(info.pluginInstalled).toBe(true)
})
it("returns correct name for each provider", () => {
// given each provider
// when getting info
// then should have correct names
expect(auth.getAuthProviderInfo("anthropic").name).toContain("Claude")
expect(auth.getAuthProviderInfo("openai").name).toContain("ChatGPT")
expect(auth.getAuthProviderInfo("google").name).toContain("Gemini")
})
})
describe("checkAuthProvider", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns pass when plugin installed", async () => {
// given plugin installed
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
id: "anthropic",
name: "Anthropic (Claude)",
pluginInstalled: true,
configured: true,
})
// when checking
const result = await auth.checkAuthProvider("anthropic")
// then should pass
expect(result.status).toBe("pass")
})
it("returns skip when plugin not installed", async () => {
// given plugin not installed
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
id: "openai",
name: "OpenAI (ChatGPT)",
pluginInstalled: false,
configured: false,
})
// when checking
const result = await auth.checkAuthProvider("openai")
// then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("not installed")
})
})
describe("checkAnthropicAuth", () => {
it("returns a check result", async () => {
// given
// when checking anthropic
const result = await auth.checkAnthropicAuth()
// then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("checkOpenAIAuth", () => {
it("returns a check result", async () => {
// given
// when checking openai
const result = await auth.checkOpenAIAuth()
// then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("checkGoogleAuth", () => {
it("returns a check result", async () => {
// given
// when checking google
const result = await auth.checkGoogleAuth()
// then should return valid result
expect(result.name).toBeDefined()
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
})
})
describe("getAuthCheckDefinitions", () => {
it("returns definitions for all three providers", () => {
// given
// when getting definitions
const defs = auth.getAuthCheckDefinitions()
// then should have 3 definitions
expect(defs.length).toBe(3)
expect(defs.every((d) => d.category === "authentication")).toBe(true)
})
})
})

View File

@@ -0,0 +1,114 @@
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, getOpenCodeConfigDir } from "../../../shared"
const OPENCODE_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const AUTH_PLUGINS: Record<AuthProviderId, { plugin: string; name: string }> = {
anthropic: { plugin: "builtin", name: "Anthropic (Claude)" },
openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI (ChatGPT)" },
google: { plugin: "opencode-antigravity-auth", name: "Google (Gemini)" },
}
function getOpenCodeConfig(): { plugin?: string[] } | null {
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
if (!existsSync(configPath)) return null
try {
const content = readFileSync(configPath, "utf-8")
return parseJsonc<{ plugin?: string[] }>(content)
} catch {
return null
}
}
function isPluginInstalled(plugins: string[], pluginName: string): boolean {
if (pluginName === "builtin") return true
return plugins.some((p) => p === pluginName || p.startsWith(`${pluginName}@`))
}
export function getAuthProviderInfo(providerId: AuthProviderId): AuthProviderInfo {
const config = getOpenCodeConfig()
const plugins = config?.plugin ?? []
const authConfig = AUTH_PLUGINS[providerId]
const pluginInstalled = isPluginInstalled(plugins, authConfig.plugin)
return {
id: providerId,
name: authConfig.name,
pluginInstalled,
configured: pluginInstalled,
}
}
export async function checkAuthProvider(providerId: AuthProviderId): Promise<CheckResult> {
const info = getAuthProviderInfo(providerId)
const checkId = `auth-${providerId}` as keyof typeof CHECK_NAMES
const checkName = CHECK_NAMES[checkId] || info.name
if (!info.pluginInstalled) {
return {
name: checkName,
status: "skip",
message: "Auth plugin not installed",
details: [
`Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
"Run: bunx oh-my-opencode install",
],
}
}
return {
name: checkName,
status: "pass",
message: "Auth plugin available",
details: [
providerId === "anthropic"
? "Run: opencode auth login (select Anthropic)"
: `Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
],
}
}
export async function checkAnthropicAuth(): Promise<CheckResult> {
return checkAuthProvider("anthropic")
}
export async function checkOpenAIAuth(): Promise<CheckResult> {
return checkAuthProvider("openai")
}
export async function checkGoogleAuth(): Promise<CheckResult> {
return checkAuthProvider("google")
}
export function getAuthCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.AUTH_ANTHROPIC,
name: CHECK_NAMES[CHECK_IDS.AUTH_ANTHROPIC],
category: "authentication",
check: checkAnthropicAuth,
critical: false,
},
{
id: CHECK_IDS.AUTH_OPENAI,
name: CHECK_NAMES[CHECK_IDS.AUTH_OPENAI],
category: "authentication",
check: checkOpenAIAuth,
critical: false,
},
{
id: CHECK_IDS.AUTH_GOOGLE,
name: CHECK_NAMES[CHECK_IDS.AUTH_GOOGLE],
category: "authentication",
check: checkGoogleAuth,
critical: false,
},
]
}

View File

@@ -1,27 +1,103 @@
import { describe, it, expect } from "bun:test"
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as config from "./config"
describe("config check", () => {
describe("checkConfig", () => {
it("returns a valid CheckResult", async () => {
//#given config check is available
//#when running the consolidated config check
const result = await config.checkConfig()
describe("validateConfig", () => {
it("returns valid: false for non-existent file", () => {
// given non-existent file path
// when validating
const result = config.validateConfig("/non/existent/path.json")
//#then should return a properly shaped CheckResult
expect(result.name).toBe("Configuration")
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
expect(typeof result.message).toBe("string")
expect(Array.isArray(result.issues)).toBe(true)
// then should indicate invalid
expect(result.valid).toBe(false)
expect(result.errors.length).toBeGreaterThan(0)
})
})
describe("getConfigInfo", () => {
it("returns exists: false when no config found", () => {
// given no config file exists
// when getting config info
const info = config.getConfigInfo()
// then should handle gracefully
expect(typeof info.exists).toBe("boolean")
expect(typeof info.valid).toBe("boolean")
})
})
describe("checkConfigValidity", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("includes issues array even when config is valid", async () => {
//#given a normal environment
//#when running config check
const result = await config.checkConfig()
it("returns pass when no config exists (uses defaults)", async () => {
// given no config file
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: false,
path: null,
format: null,
valid: true,
errors: [],
})
//#then issues should be an array (possibly empty)
expect(Array.isArray(result.issues)).toBe(true)
// when checking validity
const result = await config.checkConfigValidity()
// then should pass with default message
expect(result.status).toBe("pass")
expect(result.message).toContain("default")
})
it("returns pass when config is valid", async () => {
// given valid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: true,
errors: [],
})
// when checking validity
const result = await config.checkConfigValidity()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("JSON")
})
it("returns fail when config has validation errors", async () => {
// given invalid config
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
exists: true,
path: "/home/user/.config/opencode/oh-my-opencode.json",
format: "json",
valid: false,
errors: ["agents.oracle: Invalid model format"],
})
// when checking validity
const result = await config.checkConfigValidity()
// then should fail with errors
expect(result.status).toBe("fail")
expect(result.details?.some((d) => d.includes("Error"))).toBe(true)
})
})
describe("getConfigCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = config.getConfigCheckDefinition()
// then should have required properties
expect(def.id).toBe("config-validation")
expect(def.category).toBe("configuration")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -1,164 +1,122 @@
import { readFileSync } from "node:fs"
import { existsSync, readFileSync } from "node:fs"
import { join } from "node:path"
import { OhMyOpenCodeConfigSchema } from "../../../config"
import { detectConfigFile, getOpenCodeConfigDir, parseJsonc } from "../../../shared"
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { getModelResolutionInfoWithOverrides } from "./model-resolution"
import type { OmoConfig } from "./model-resolution-types"
import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared"
import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_BASE = join(getOpenCodeConfigDir({ binary: "opencode" }), PACKAGE_NAME)
const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
interface ConfigValidationResult {
exists: boolean
path: string | null
valid: boolean
config: OmoConfig | null
errors: string[]
}
function findConfigPath(): { path: string; format: "json" | "jsonc" } | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" }
}
function findConfigPath(): string | null {
const projectConfig = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectConfig.format !== "none") return projectConfig.path
const userConfig = detectConfigFile(USER_CONFIG_BASE)
if (userConfig.format !== "none") return userConfig.path
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
}
return null
}
function validateConfig(): ConfigValidationResult {
const configPath = findConfigPath()
if (!configPath) {
return { exists: false, path: null, valid: true, config: null, errors: [] }
}
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
try {
const content = readFileSync(configPath, "utf-8")
const rawConfig = parseJsonc<OmoConfig>(content)
const schemaResult = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
const rawConfig = parseJsonc<Record<string, unknown>>(content)
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
if (!schemaResult.success) {
return {
exists: true,
path: configPath,
valid: false,
config: rawConfig,
errors: schemaResult.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`),
}
if (!result.success) {
const errors = result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
)
return { valid: false, errors }
}
return { exists: true, path: configPath, valid: true, config: rawConfig, errors: [] }
} catch (error) {
return { valid: true, errors: [] }
} catch (err) {
return {
exists: true,
path: configPath,
valid: false,
config: null,
errors: [error instanceof Error ? error.message : "Failed to parse config"],
errors: [err instanceof Error ? err.message : "Failed to parse config"],
}
}
}
function collectModelResolutionIssues(config: OmoConfig): DoctorIssue[] {
const issues: DoctorIssue[] = []
const availableModels = loadAvailableModelsFromCache()
const resolution = getModelResolutionInfoWithOverrides(config)
export function getConfigInfo(): ConfigInfo {
const configPath = findConfigPath()
const invalidAgentOverrides = resolution.agents.filter(
(agent) => agent.userOverride && !agent.userOverride.includes("/")
)
const invalidCategoryOverrides = resolution.categories.filter(
(category) => category.userOverride && !category.userOverride.includes("/")
)
for (const invalidAgent of invalidAgentOverrides) {
issues.push({
title: `Invalid agent override: ${invalidAgent.name}`,
description: `Override '${invalidAgent.userOverride}' must be in provider/model format.`,
severity: "warning",
affects: [invalidAgent.name],
})
}
for (const invalidCategory of invalidCategoryOverrides) {
issues.push({
title: `Invalid category override: ${invalidCategory.name}`,
description: `Override '${invalidCategory.userOverride}' must be in provider/model format.`,
severity: "warning",
affects: [invalidCategory.name],
})
}
if (availableModels.cacheExists) {
const providerSet = new Set(availableModels.providers)
const unknownProviders = [
...resolution.agents.map((agent) => agent.userOverride),
...resolution.categories.map((category) => category.userOverride),
]
.filter((value): value is string => Boolean(value))
.map((value) => value.split("/")[0])
.filter((provider) => provider.length > 0 && !providerSet.has(provider))
if (unknownProviders.length > 0) {
const uniqueProviders = [...new Set(unknownProviders)]
issues.push({
title: "Model override uses unavailable provider",
description: `Provider(s) not found in OpenCode model cache: ${uniqueProviders.join(", ")}`,
severity: "warning",
affects: ["model resolution"],
})
}
}
return issues
}
export async function checkConfig(): Promise<CheckResult> {
const validation = validateConfig()
const issues: DoctorIssue[] = []
if (!validation.exists) {
if (!configPath) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG],
exists: false,
path: null,
format: null,
valid: true,
errors: [],
}
}
if (!existsSync(configPath.path)) {
return {
exists: false,
path: configPath.path,
format: configPath.format,
valid: true,
errors: [],
}
}
const validation = validateConfig(configPath.path)
return {
exists: true,
path: configPath.path,
format: configPath.format,
valid: validation.valid,
errors: validation.errors,
}
}
export async function checkConfigValidity(): Promise<CheckResult> {
const info = getConfigInfo()
if (!info.exists) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: "No custom config found; defaults are used",
details: undefined,
issues,
message: "Using default configuration",
details: ["No custom config file found (optional)"],
}
}
if (!validation.valid) {
issues.push(
...validation.errors.map((error) => ({
title: "Invalid configuration",
description: error,
severity: "error" as const,
affects: ["plugin startup"],
}))
)
if (!info.valid) {
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG],
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "fail",
message: `Configuration invalid (${issues.length} issue${issues.length > 1 ? "s" : ""})`,
details: validation.path ? [`Path: ${validation.path}`] : undefined,
issues,
message: "Configuration has validation errors",
details: [
`Path: ${info.path}`,
...info.errors.map((e) => `Error: ${e}`),
],
}
}
if (validation.config) {
issues.push(...collectModelResolutionIssues(validation.config))
}
return {
name: CHECK_NAMES[CHECK_IDS.CONFIG],
status: issues.length > 0 ? "warn" : "pass",
message: issues.length > 0 ? `${issues.length} configuration warning(s)` : "Configuration is valid",
details: validation.path ? [`Path: ${validation.path}`] : undefined,
issues,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
status: "pass",
message: `Valid ${info.format?.toUpperCase()} config`,
details: [`Path: ${info.path}`],
}
}
export function getConfigCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.CONFIG_VALIDATION,
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
category: "configuration",
check: checkConfigValidity,
critical: false,
}
}

View File

@@ -1,29 +1,27 @@
import { describe, it, expect } from "bun:test"
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as deps from "./dependencies"
describe("dependencies check", () => {
describe("checkAstGrepCli", () => {
it("returns valid dependency info", async () => {
//#given ast-grep cli check
//#when checking
it("returns dependency info", async () => {
// given
// when checking ast-grep cli
const info = await deps.checkAstGrepCli()
//#then should return valid DependencyInfo
// then should return valid info
expect(info.name).toBe("AST-Grep CLI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
expect(typeof info.version === "string" || info.version === null).toBe(true)
expect(typeof info.path === "string" || info.path === null).toBe(true)
})
})
describe("checkAstGrepNapi", () => {
it("returns valid dependency info", async () => {
//#given ast-grep napi check
//#when checking
it("returns dependency info", async () => {
// given
// when checking ast-grep napi
const info = await deps.checkAstGrepNapi()
//#then should return valid DependencyInfo
// then should return valid info
expect(info.name).toBe("AST-Grep NAPI")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
@@ -31,15 +29,124 @@ describe("dependencies check", () => {
})
describe("checkCommentChecker", () => {
it("returns valid dependency info", async () => {
//#given comment checker check
//#when checking
it("returns dependency info", async () => {
// given
// when checking comment checker
const info = await deps.checkCommentChecker()
//#then should return valid DependencyInfo
// then should return valid info
expect(info.name).toBe("Comment Checker")
expect(info.required).toBe(false)
expect(typeof info.installed).toBe("boolean")
})
})
describe("checkDependencyAstGrepCli", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns pass when installed", async () => {
// given ast-grep installed
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
name: "AST-Grep CLI",
required: false,
installed: true,
version: "0.25.0",
path: "/usr/local/bin/sg",
})
// when checking
const result = await deps.checkDependencyAstGrepCli()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("0.25.0")
})
it("returns warn when not installed", async () => {
// given ast-grep not installed
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
name: "AST-Grep CLI",
required: false,
installed: false,
version: null,
path: null,
installHint: "Install: npm install -g @ast-grep/cli",
})
// when checking
const result = await deps.checkDependencyAstGrepCli()
// then should warn (optional)
expect(result.status).toBe("warn")
expect(result.message).toContain("optional")
})
})
describe("checkDependencyAstGrepNapi", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns pass when installed", async () => {
// given napi installed
checkSpy = spyOn(deps, "checkAstGrepNapi").mockResolvedValue({
name: "AST-Grep NAPI",
required: false,
installed: true,
version: null,
path: null,
})
// when checking
const result = await deps.checkDependencyAstGrepNapi()
// then should pass
expect(result.status).toBe("pass")
})
})
describe("checkDependencyCommentChecker", () => {
let checkSpy: ReturnType<typeof spyOn>
afterEach(() => {
checkSpy?.mockRestore()
})
it("returns warn when not installed", async () => {
// given comment checker not installed
checkSpy = spyOn(deps, "checkCommentChecker").mockResolvedValue({
name: "Comment Checker",
required: false,
installed: false,
version: null,
path: null,
installHint: "Hook will be disabled if not available",
})
// when checking
const result = await deps.checkDependencyCommentChecker()
// then should warn
expect(result.status).toBe("warn")
})
})
describe("getDependencyCheckDefinitions", () => {
it("returns definitions for all dependencies", () => {
// given
// when getting definitions
const defs = deps.getDependencyCheckDefinitions()
// then should have 3 definitions
expect(defs.length).toBe(3)
expect(defs.every((d) => d.category === "dependencies")).toBe(true)
expect(defs.every((d) => d.critical === false)).toBe(true)
})
})
})

View File

@@ -1,8 +1,5 @@
import { existsSync } from "node:fs"
import { createRequire } from "node:module"
import { dirname, join } from "node:path"
import type { DependencyInfo } from "../types"
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
@@ -102,24 +99,10 @@ export async function checkAstGrepNapi(): Promise<DependencyInfo> {
}
}
function findCommentCheckerPackageBinary(): string | null {
const binaryName = process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
try {
const require = createRequire(import.meta.url)
const pkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
const binaryPath = join(dirname(pkgPath), "bin", binaryName)
if (existsSync(binaryPath)) return binaryPath
} catch {
// intentionally empty - package not installed
}
return null
}
export async function checkCommentChecker(): Promise<DependencyInfo> {
const binaryCheck = await checkBinaryExists("comment-checker")
const resolvedPath = binaryCheck.exists ? binaryCheck.path : findCommentCheckerPackageBinary()
if (!resolvedPath) {
if (!binaryCheck.exists) {
return {
name: "Comment Checker",
required: false,
@@ -130,14 +113,72 @@ export async function checkCommentChecker(): Promise<DependencyInfo> {
}
}
const version = await getBinaryVersion(resolvedPath)
const version = await getBinaryVersion("comment-checker")
return {
name: "Comment Checker",
required: false,
installed: true,
version,
path: resolvedPath,
path: binaryCheck.path,
}
}
function dependencyToCheckResult(dep: DependencyInfo, checkName: string): CheckResult {
if (dep.installed) {
return {
name: checkName,
status: "pass",
message: dep.version ?? "installed",
details: dep.path ? [`Path: ${dep.path}`] : undefined,
}
}
return {
name: checkName,
status: "warn",
message: "Not installed (optional)",
details: dep.installHint ? [dep.installHint] : undefined,
}
}
export async function checkDependencyAstGrepCli(): Promise<CheckResult> {
const info = await checkAstGrepCli()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI])
}
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
const info = await checkAstGrepNapi()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
}
export async function checkDependencyCommentChecker(): Promise<CheckResult> {
const info = await checkCommentChecker()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER])
}
export function getDependencyCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.DEP_AST_GREP_CLI,
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI],
category: "dependencies",
check: checkDependencyAstGrepCli,
critical: false,
},
{
id: CHECK_IDS.DEP_AST_GREP_NAPI,
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI],
category: "dependencies",
check: checkDependencyAstGrepNapi,
critical: false,
},
{
id: CHECK_IDS.DEP_COMMENT_CHECKER,
name: CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER],
category: "dependencies",
check: checkDependencyCommentChecker,
critical: false,
},
]
}

View File

@@ -0,0 +1,151 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as gh from "./gh"
describe("gh cli check", () => {
describe("getGhCliInfo", () => {
function createProc(opts: { stdout?: string; stderr?: string; exitCode?: number }) {
const stdoutText = opts.stdout ?? ""
const stderrText = opts.stderr ?? ""
const exitCode = opts.exitCode ?? 0
const encoder = new TextEncoder()
return {
stdout: new ReadableStream({
start(controller) {
if (stdoutText) controller.enqueue(encoder.encode(stdoutText))
controller.close()
},
}),
stderr: new ReadableStream({
start(controller) {
if (stderrText) controller.enqueue(encoder.encode(stderrText))
controller.close()
},
}),
exited: Promise.resolve(exitCode),
exitCode,
} as unknown as ReturnType<typeof Bun.spawn>
}
it("returns gh cli info structure", async () => {
const spawnSpy = spyOn(Bun, "spawn").mockImplementation((cmd) => {
if (Array.isArray(cmd) && (cmd[0] === "which" || cmd[0] === "where") && cmd[1] === "gh") {
return createProc({ stdout: "/usr/bin/gh\n" })
}
if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "--version") {
return createProc({ stdout: "gh version 2.40.0\n" })
}
if (Array.isArray(cmd) && cmd[0] === "gh" && cmd[1] === "auth" && cmd[2] === "status") {
return createProc({
exitCode: 0,
stderr: "Logged in to github.com account octocat (keyring)\nToken scopes: 'repo', 'read:org'\n",
})
}
throw new Error(`Unexpected Bun.spawn call: ${Array.isArray(cmd) ? cmd.join(" ") : String(cmd)}`)
})
try {
const info = await gh.getGhCliInfo()
expect(info.installed).toBe(true)
expect(info.version).toBe("2.40.0")
expect(typeof info.authenticated).toBe("boolean")
expect(Array.isArray(info.scopes)).toBe(true)
} finally {
spawnSpy.mockRestore()
}
})
})
describe("checkGhCli", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns warn when gh is not installed", async () => {
// given gh not installed
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: false,
version: null,
path: null,
authenticated: false,
username: null,
scopes: [],
error: null,
})
// when checking
const result = await gh.checkGhCli()
// then should warn (optional)
expect(result.status).toBe("warn")
expect(result.message).toContain("Not installed")
expect(result.details).toContain("Install: https://cli.github.com/")
})
it("returns warn when gh is installed but not authenticated", async () => {
// given gh installed but not authenticated
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: true,
version: "2.40.0",
path: "/usr/local/bin/gh",
authenticated: false,
username: null,
scopes: [],
error: "not logged in",
})
// when checking
const result = await gh.checkGhCli()
// then should warn about auth
expect(result.status).toBe("warn")
expect(result.message).toContain("2.40.0")
expect(result.message).toContain("not authenticated")
expect(result.details).toContain("Authenticate: gh auth login")
})
it("returns pass when gh is installed and authenticated", async () => {
// given gh installed and authenticated
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: true,
version: "2.40.0",
path: "/usr/local/bin/gh",
authenticated: true,
username: "octocat",
scopes: ["repo", "read:org"],
error: null,
})
// when checking
const result = await gh.checkGhCli()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2.40.0")
expect(result.message).toContain("octocat")
expect(result.details).toContain("Account: octocat")
expect(result.details).toContain("Scopes: repo, read:org")
})
})
describe("getGhCliCheckDefinition", () => {
it("returns correct check definition", () => {
// given
// when getting definition
const def = gh.getGhCliCheckDefinition()
// then should have correct properties
expect(def.id).toBe("gh-cli")
expect(def.name).toBe("GitHub CLI")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
expect(typeof def.check).toBe("function")
})
})
})

172
src/cli/doctor/checks/gh.ts Normal file
View File

@@ -0,0 +1,172 @@
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
export interface GhCliInfo {
installed: boolean
version: string | null
path: string | null
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
const whichCmd = process.platform === "win32" ? "where" : "which"
const proc = Bun.spawn([whichCmd, binary], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { exists: true, path: output.trim() }
}
} catch {
// intentionally empty - binary not found
}
return { exists: false, path: null }
}
async function getGhVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const match = output.match(/gh version (\S+)/)
return match?.[1] ?? output.trim().split("\n")[0]
}
} catch {
// intentionally empty - version unavailable
}
return null
}
async function getGhAuthStatus(): Promise<{
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}> {
try {
const proc = Bun.spawn(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
await proc.exited
const output = stderr || stdout
if (proc.exitCode === 0) {
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
const scopes = scopesMatch?.[1]
? scopesMatch[1]
.split(/,\s*/)
.map((s) => s.replace(/['"]/g, "").trim())
.filter(Boolean)
: []
return { authenticated: true, username, scopes, error: null }
}
const errorMatch = output.match(/error[:\s]+(.+)/i)
return {
authenticated: false,
username: null,
scopes: [],
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
}
} catch (err) {
return {
authenticated: false,
username: null,
scopes: [],
error: err instanceof Error ? err.message : "Failed to check auth status",
}
}
}
export async function getGhCliInfo(): Promise<GhCliInfo> {
const binaryCheck = await checkBinaryExists("gh")
if (!binaryCheck.exists) {
return {
installed: false,
version: null,
path: null,
authenticated: false,
username: null,
scopes: [],
error: null,
}
}
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
return {
installed: true,
version,
path: binaryCheck.path,
authenticated: authStatus.authenticated,
username: authStatus.username,
scopes: authStatus.scopes,
error: authStatus.error,
}
}
export async function checkGhCli(): Promise<CheckResult> {
const info = await getGhCliInfo()
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
if (!info.installed) {
return {
name,
status: "warn",
message: "Not installed (optional)",
details: [
"GitHub CLI is used by librarian agent and scripts",
"Install: https://cli.github.com/",
],
}
}
if (!info.authenticated) {
return {
name,
status: "warn",
message: `${info.version ?? "installed"} - not authenticated`,
details: [
info.path ? `Path: ${info.path}` : null,
"Authenticate: gh auth login",
info.error ? `Error: ${info.error}` : null,
].filter((d): d is string => d !== null),
}
}
const details: string[] = []
if (info.path) details.push(`Path: ${info.path}`)
if (info.username) details.push(`Account: ${info.username}`)
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
return {
name,
status: "pass",
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
details: details.length > 0 ? details : undefined,
}
}
export function getGhCliCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.GH_CLI,
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
category: "tools",
check: checkGhCli,
critical: false,
}
}

View File

@@ -1,36 +1,46 @@
import type { CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { checkSystem, gatherSystemInfo } from "./system"
import { checkConfig } from "./config"
import { checkTools, gatherToolsSummary } from "./tools"
import { checkModels } from "./model-resolution"
import { getOpenCodeCheckDefinition } from "./opencode"
import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getModelResolutionCheckDefinition } from "./model-resolution"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getGhCliCheckDefinition } from "./gh"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
import { getVersionCheckDefinition } from "./version"
export type { CheckDefinition }
export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./model-resolution"
export * from "./model-resolution-types"
export { gatherSystemInfo, gatherToolsSummary }
export * from "./model-resolution-cache"
export * from "./model-resolution-config"
export * from "./model-resolution-effective-model"
export * from "./model-resolution-variant"
export * from "./model-resolution-details"
export * from "./auth"
export * from "./dependencies"
export * from "./gh"
export * from "./lsp"
export * from "./mcp"
export * from "./mcp-oauth"
export * from "./version"
export function getAllCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.SYSTEM,
name: CHECK_NAMES[CHECK_IDS.SYSTEM],
check: checkSystem,
critical: true,
},
{
id: CHECK_IDS.CONFIG,
name: CHECK_NAMES[CHECK_IDS.CONFIG],
check: checkConfig,
},
{
id: CHECK_IDS.TOOLS,
name: CHECK_NAMES[CHECK_IDS.TOOLS],
check: checkTools,
},
{
id: CHECK_IDS.MODELS,
name: CHECK_NAMES[CHECK_IDS.MODELS],
check: checkModels,
},
getOpenCodeCheckDefinition(),
getPluginCheckDefinition(),
getConfigCheckDefinition(),
getModelResolutionCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getGhCliCheckDefinition(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getMcpOAuthCheckDefinition(),
getVersionCheckDefinition(),
]
}

View File

@@ -0,0 +1,134 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as lsp from "./lsp"
import type { LspServerInfo } from "../types"
describe("lsp check", () => {
describe("getLspServersInfo", () => {
it("returns array of server info", async () => {
// given
// when getting servers info
const servers = await lsp.getLspServersInfo()
// then should return array with expected structure
expect(Array.isArray(servers)).toBe(true)
servers.forEach((s) => {
expect(s.id).toBeDefined()
expect(typeof s.installed).toBe("boolean")
expect(Array.isArray(s.extensions)).toBe(true)
})
})
it("does not spawn 'which' command (windows compatibility)", async () => {
// given
const spawnSpy = spyOn(Bun, "spawn")
try {
// when getting servers info
await lsp.getLspServersInfo()
// then should not spawn which
const calls = spawnSpy.mock.calls
const whichCalls = calls.filter((c) => Array.isArray(c) && Array.isArray(c[0]) && c[0][0] === "which")
expect(whichCalls.length).toBe(0)
} finally {
spawnSpy.mockRestore()
}
})
})
describe("getLspServerStats", () => {
it("counts installed servers correctly", () => {
// given servers with mixed installation status
const servers = [
{ id: "ts", installed: true, extensions: [".ts"], source: "builtin" as const },
{ id: "py", installed: false, extensions: [".py"], source: "builtin" as const },
{ id: "go", installed: true, extensions: [".go"], source: "builtin" as const },
]
// when getting stats
const stats = lsp.getLspServerStats(servers)
// then should count correctly
expect(stats.installed).toBe(2)
expect(stats.total).toBe(3)
})
it("handles empty array", () => {
// given no servers
const servers: LspServerInfo[] = []
// when getting stats
const stats = lsp.getLspServerStats(servers)
// then should return zeros
expect(stats.installed).toBe(0)
expect(stats.total).toBe(0)
})
})
describe("checkLspServers", () => {
let getServersSpy: ReturnType<typeof spyOn>
afterEach(() => {
getServersSpy?.mockRestore()
})
it("returns warn when no servers installed", async () => {
// given no servers installed
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: false, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// when checking
const result = await lsp.checkLspServers()
// then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("No LSP servers")
})
it("returns pass when servers installed", async () => {
// given some servers installed
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// when checking
const result = await lsp.checkLspServers()
// then should pass with count
expect(result.status).toBe("pass")
expect(result.message).toContain("1/2")
})
it("lists installed and missing servers in details", async () => {
// given mixed installation
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
])
// when checking
const result = await lsp.checkLspServers()
// then should list both
expect(result.details?.some((d) => d.includes("Installed"))).toBe(true)
expect(result.details?.some((d) => d.includes("Not found"))).toBe(true)
})
})
describe("getLspCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = lsp.getLspCheckDefinition()
// then should have required properties
expect(def.id).toBe("lsp-servers")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -0,0 +1,77 @@
import type { CheckResult, CheckDefinition, LspServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
const DEFAULT_LSP_SERVERS: Array<{
id: string
binary: string
extensions: string[]
}> = [
{ id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] },
{ id: "pyright", binary: "pyright-langserver", extensions: [".py"] },
{ id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] },
{ id: "gopls", binary: "gopls", extensions: [".go"] },
]
import { isServerInstalled } from "../../../tools/lsp/config"
export async function getLspServersInfo(): Promise<LspServerInfo[]> {
const servers: LspServerInfo[] = []
for (const server of DEFAULT_LSP_SERVERS) {
const installed = isServerInstalled([server.binary])
servers.push({
id: server.id,
installed,
extensions: server.extensions,
source: "builtin",
})
}
return servers
}
export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } {
const installed = servers.filter((s) => s.installed).length
return { installed, total: servers.length }
}
export async function checkLspServers(): Promise<CheckResult> {
const servers = await getLspServersInfo()
const stats = getLspServerStats(servers)
const installedServers = servers.filter((s) => s.installed)
const missingServers = servers.filter((s) => !s.installed)
if (stats.installed === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
status: "warn",
message: "No LSP servers detected",
details: [
"LSP tools will have limited functionality",
...missingServers.map((s) => `Missing: ${s.id}`),
],
}
}
const details = [
...installedServers.map((s) => `Installed: ${s.id}`),
...missingServers.map((s) => `Not found: ${s.id} (optional)`),
]
return {
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
status: "pass",
message: `${stats.installed}/${stats.total} servers available`,
details,
}
}
export function getLspCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.LSP_SERVERS,
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
category: "tools",
check: checkLspServers,
critical: false,
}
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as mcpOauth from "./mcp-oauth"
describe("mcp-oauth check", () => {
describe("getMcpOAuthCheckDefinition", () => {
it("returns check definition with correct properties", () => {
// given
// when getting definition
const def = mcpOauth.getMcpOAuthCheckDefinition()
// then should have correct structure
expect(def.id).toBe("mcp-oauth-tokens")
expect(def.name).toBe("MCP OAuth Tokens")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
expect(typeof def.check).toBe("function")
})
})
describe("checkMcpOAuthTokens", () => {
let readStoreSpy: ReturnType<typeof spyOn>
afterEach(() => {
readStoreSpy?.mockRestore()
})
it("returns skip when no tokens stored", async () => {
// given no OAuth tokens configured
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null)
// when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("No OAuth")
})
it("returns pass when all tokens valid", async () => {
// given valid tokens with future expiry (expiresAt is in epoch seconds)
const futureTime = Math.floor(Date.now() / 1000) + 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
expiresAt: futureTime,
},
"example.com/resource2": {
accessToken: "token2",
expiresAt: futureTime,
},
})
// when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2")
expect(result.message).toContain("valid")
})
it("returns warn when some tokens expired", async () => {
// given mix of valid and expired tokens (expiresAt is in epoch seconds)
const futureTime = Math.floor(Date.now() / 1000) + 3600
const pastTime = Math.floor(Date.now() / 1000) - 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
expiresAt: futureTime,
},
"example.com/resource2": {
accessToken: "token2",
expiresAt: pastTime,
},
})
// when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("1")
expect(result.message).toContain("expired")
expect(result.details?.some((d: string) => d.includes("Expired"))).toBe(
true
)
})
it("returns pass when tokens have no expiry", async () => {
// given tokens without expiry info
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
},
})
// when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// then should pass (no expiry = assume valid)
expect(result.status).toBe("pass")
expect(result.message).toContain("1")
})
it("includes token details in output", async () => {
// given multiple tokens
const futureTime = Math.floor(Date.now() / 1000) + 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"api.example.com/v1": {
accessToken: "token1",
expiresAt: futureTime,
},
"auth.example.com/oauth": {
accessToken: "token2",
expiresAt: futureTime,
},
})
// when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// then should list tokens in details
expect(result.details).toBeDefined()
expect(result.details?.length).toBeGreaterThan(0)
expect(
result.details?.some((d: string) => d.includes("api.example.com"))
).toBe(true)
expect(
result.details?.some((d: string) => d.includes("auth.example.com"))
).toBe(true)
})
})
})

View File

@@ -0,0 +1,80 @@
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage"
import { existsSync, readFileSync } from "node:fs"
interface OAuthTokenData {
accessToken: string
refreshToken?: string
expiresAt?: number
clientInfo?: {
clientId: string
clientSecret?: string
}
}
type TokenStore = Record<string, OAuthTokenData>
export function readTokenStore(): TokenStore | null {
const filePath = getMcpOauthStoragePath()
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
return JSON.parse(content) as TokenStore
} catch {
return null
}
}
export async function checkMcpOAuthTokens(): Promise<CheckResult> {
const store = readTokenStore()
if (!store || Object.keys(store).length === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "skip",
message: "No OAuth tokens configured",
details: ["Optional: Configure OAuth tokens for MCP servers"],
}
}
const now = Math.floor(Date.now() / 1000)
const tokens = Object.entries(store)
const expiredTokens = tokens.filter(
([, token]) => token.expiresAt && token.expiresAt < now
)
if (expiredTokens.length > 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "warn",
message: `${expiredTokens.length} of ${tokens.length} token(s) expired`,
details: [
...tokens
.filter(([, token]) => !token.expiresAt || token.expiresAt >= now)
.map(([key]) => `Valid: ${key}`),
...expiredTokens.map(([key]) => `Expired: ${key}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "pass",
message: `${tokens.length} OAuth token(s) valid`,
details: tokens.map(([key]) => `Configured: ${key}`),
}
}
export function getMcpOAuthCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.MCP_OAUTH_TOKENS,
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
category: "tools",
check: checkMcpOAuthTokens,
critical: false,
}
}

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as mcp from "./mcp"
describe("mcp check", () => {
describe("getBuiltinMcpInfo", () => {
it("returns builtin servers", () => {
// given
// when getting builtin info
const servers = mcp.getBuiltinMcpInfo()
// then should include expected servers
expect(servers.length).toBe(2)
expect(servers.every((s) => s.type === "builtin")).toBe(true)
expect(servers.every((s) => s.enabled === true)).toBe(true)
expect(servers.map((s) => s.id)).toContain("context7")
expect(servers.map((s) => s.id)).toContain("grep_app")
})
})
describe("getUserMcpInfo", () => {
it("returns empty array when no user config", () => {
// given no user config exists
// when getting user info
const servers = mcp.getUserMcpInfo()
// then should return array (may be empty)
expect(Array.isArray(servers)).toBe(true)
})
})
describe("checkBuiltinMcpServers", () => {
it("returns pass with server count", async () => {
// given
// when checking builtin servers
const result = await mcp.checkBuiltinMcpServers()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2")
expect(result.message).toContain("enabled")
})
it("lists enabled servers in details", async () => {
// given
// when checking builtin servers
const result = await mcp.checkBuiltinMcpServers()
// then should list servers
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
})
})
describe("checkUserMcpServers", () => {
let getUserSpy: ReturnType<typeof spyOn>
afterEach(() => {
getUserSpy?.mockRestore()
})
it("returns skip when no user config", async () => {
// given no user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([])
// when checking
const result = await mcp.checkUserMcpServers()
// then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("No user MCP")
})
it("returns pass when valid user servers", async () => {
// given valid user servers
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
{ id: "custom-mcp", type: "user", enabled: true, valid: true },
])
// when checking
const result = await mcp.checkUserMcpServers()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("1")
})
it("returns warn when servers have issues", async () => {
// given invalid server config
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
{ id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" },
])
// when checking
const result = await mcp.checkUserMcpServers()
// then should warn
expect(result.status).toBe("warn")
expect(result.details?.some((d) => d.includes("Invalid"))).toBe(true)
})
})
describe("getMcpCheckDefinitions", () => {
it("returns definitions for builtin and user", () => {
// given
// when getting definitions
const defs = mcp.getMcpCheckDefinitions()
// then should have 2 definitions
expect(defs.length).toBe(2)
expect(defs.every((d) => d.category === "tools")).toBe(true)
expect(defs.map((d) => d.id)).toContain("mcp-builtin")
expect(defs.map((d) => d.id)).toContain("mcp-user")
})
})
})

View File

@@ -0,0 +1,128 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
const MCP_CONFIG_PATHS = [
join(homedir(), ".claude", ".mcp.json"),
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".claude", ".mcp.json"),
]
interface McpConfig {
mcpServers?: Record<string, unknown>
}
function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}
for (const configPath of MCP_CONFIG_PATHS) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfig>(content)
if (config.mcpServers) {
Object.assign(servers, config.mcpServers)
}
} catch {
// intentionally empty - skip invalid configs
}
}
return servers
}
export function getBuiltinMcpInfo(): McpServerInfo[] {
return BUILTIN_MCP_SERVERS.map((id) => ({
id,
type: "builtin" as const,
enabled: true,
valid: true,
}))
}
export function getUserMcpInfo(): McpServerInfo[] {
const userServers = loadUserMcpConfig()
const servers: McpServerInfo[] = []
for (const [id, config] of Object.entries(userServers)) {
const isValid = typeof config === "object" && config !== null
servers.push({
id,
type: "user",
enabled: true,
valid: isValid,
error: isValid ? undefined : "Invalid configuration format",
})
}
return servers
}
export async function checkBuiltinMcpServers(): Promise<CheckResult> {
const servers = getBuiltinMcpInfo()
return {
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
status: "pass",
message: `${servers.length} built-in servers enabled`,
details: servers.map((s) => `Enabled: ${s.id}`),
}
}
export async function checkUserMcpServers(): Promise<CheckResult> {
const servers = getUserMcpInfo()
if (servers.length === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "skip",
message: "No user MCP configuration found",
details: ["Optional: Add .mcp.json for custom MCP servers"],
}
}
const invalidServers = servers.filter((s) => !s.valid)
if (invalidServers.length > 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "warn",
message: `${invalidServers.length} server(s) have configuration issues`,
details: [
...servers.filter((s) => s.valid).map((s) => `Valid: ${s.id}`),
...invalidServers.map((s) => `Invalid: ${s.id} - ${s.error}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
status: "pass",
message: `${servers.length} user server(s) configured`,
details: servers.map((s) => `Configured: ${s.id}`),
}
}
export function getMcpCheckDefinitions(): CheckDefinition[] {
return [
{
id: CHECK_IDS.MCP_BUILTIN,
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
category: "tools",
check: checkBuiltinMcpServers,
critical: false,
},
{
id: CHECK_IDS.MCP_USER,
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
category: "tools",
check: checkUserMcpServers,
critical: false,
},
]
}

View File

@@ -165,4 +165,16 @@ describe("model-resolution check", () => {
})
})
describe("getModelResolutionCheckDefinition", () => {
it("returns valid check definition", async () => {
const { getModelResolutionCheckDefinition } = await import("./model-resolution")
const def = getModelResolutionCheckDefinition()
expect(def.id).toBe("model-resolution")
expect(def.name).toBe("Model Resolution")
expect(def.category).toBe("configuration")
expect(typeof def.check).toBe("function")
})
})
})

View File

@@ -1,19 +1,24 @@
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
} from "../../../shared/model-requirements"
import type { OmoConfig, ModelResolutionInfo, AgentResolutionInfo, CategoryResolutionInfo } from "./model-resolution-types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
import { loadOmoConfig } from "./model-resolution-config"
import { buildModelResolutionDetails } from "./model-resolution-details"
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
import { buildModelResolutionDetails } from "./model-resolution-details"
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}))
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}),
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
@@ -21,25 +26,27 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
})
}),
)
return { agents, categories }
}
export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
const userOverride = config.agents?.[name]?.model
const userVariant = config.agents?.[name]?.variant
return {
name,
requirement,
userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
})
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.agents?.[name]?.model
const userVariant = config.agents?.[name]?.variant
return {
name,
requirement,
userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
},
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
@@ -53,39 +60,40 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
}
},
)
return { agents, categories }
}
export async function checkModels(): Promise<CheckResult> {
export async function checkModelResolution(): Promise<CheckResult> {
const config = loadOmoConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModelsFromCache()
const issues: DoctorIssue[] = []
if (!available.cacheExists) {
issues.push({
title: "Model cache not found",
description: "OpenCode model cache is missing, so model availability cannot be validated.",
fix: "Run: opencode models --refresh",
severity: "warning",
affects: ["model resolution"],
})
}
const agentCount = info.agents.length
const categoryCount = info.categories.length
const agentOverrides = info.agents.filter((a) => a.userOverride).length
const categoryOverrides = info.categories.filter((c) => c.userOverride).length
const totalOverrides = agentOverrides + categoryOverrides
const overrideCount =
info.agents.filter((agent) => Boolean(agent.userOverride)).length +
info.categories.filter((category) => Boolean(category.userOverride)).length
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found"
return {
name: CHECK_NAMES[CHECK_IDS.MODELS],
status: issues.length > 0 ? "warn" : "pass",
message: `${info.agents.length} agents, ${info.categories.length} categories, ${overrideCount} override${overrideCount === 1 ? "" : "s"}`,
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildModelResolutionDetails({ info, available, config }),
issues,
}
}
export const checkModelResolution = checkModels
export function getModelResolutionCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.MODEL_RESOLUTION,
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
category: "configuration",
check: checkModelResolution,
critical: false,
}
}

View File

@@ -0,0 +1,331 @@
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
import * as opencode from "./opencode"
import { MIN_OPENCODE_VERSION } from "../constants"
describe("opencode check", () => {
describe("compareVersions", () => {
it("returns true when current >= minimum", () => {
// given versions where current is greater
// when comparing
// then should return true
expect(opencode.compareVersions("1.0.200", "1.0.150")).toBe(true)
expect(opencode.compareVersions("1.1.0", "1.0.150")).toBe(true)
expect(opencode.compareVersions("2.0.0", "1.0.150")).toBe(true)
})
it("returns true when versions are equal", () => {
// given equal versions
// when comparing
// then should return true
expect(opencode.compareVersions("1.0.150", "1.0.150")).toBe(true)
})
it("returns false when current < minimum", () => {
// given version below minimum
// when comparing
// then should return false
expect(opencode.compareVersions("1.0.100", "1.0.150")).toBe(false)
expect(opencode.compareVersions("0.9.0", "1.0.150")).toBe(false)
})
it("handles version prefixes", () => {
// given version with v prefix
// when comparing
// then should strip prefix and compare correctly
expect(opencode.compareVersions("v1.0.200", "1.0.150")).toBe(true)
})
it("handles prerelease versions", () => {
// given prerelease version
// when comparing
// then should use base version
expect(opencode.compareVersions("1.0.200-beta.1", "1.0.150")).toBe(true)
})
})
describe("command helpers", () => {
it("selects where on Windows", () => {
// given win32 platform
// when selecting lookup command
// then should use where
expect(opencode.getBinaryLookupCommand("win32")).toBe("where")
})
it("selects which on non-Windows", () => {
// given linux platform
// when selecting lookup command
// then should use which
expect(opencode.getBinaryLookupCommand("linux")).toBe("which")
expect(opencode.getBinaryLookupCommand("darwin")).toBe("which")
})
it("parses command output into paths", () => {
// given raw output with multiple lines and spaces
const output = "C:\\\\bin\\\\opencode.ps1\r\nC:\\\\bin\\\\opencode.exe\n\n"
// when parsing
const paths = opencode.parseBinaryPaths(output)
// then should return trimmed, non-empty paths
expect(paths).toEqual(["C:\\\\bin\\\\opencode.ps1", "C:\\\\bin\\\\opencode.exe"])
})
it("prefers exe/cmd/bat over ps1 on Windows", () => {
// given windows paths
const paths = [
"C:\\\\bin\\\\opencode.ps1",
"C:\\\\bin\\\\opencode.cmd",
"C:\\\\bin\\\\opencode.exe",
]
// when selecting binary
const selected = opencode.selectBinaryPath(paths, "win32")
// then should prefer exe
expect(selected).toBe("C:\\\\bin\\\\opencode.exe")
})
it("falls back to ps1 when it is the only Windows candidate", () => {
// given only ps1 path
const paths = ["C:\\\\bin\\\\opencode.ps1"]
// when selecting binary
const selected = opencode.selectBinaryPath(paths, "win32")
// then should return ps1 path
expect(selected).toBe("C:\\\\bin\\\\opencode.ps1")
})
it("builds PowerShell command for ps1 on Windows", () => {
// given a ps1 path on Windows
const command = opencode.buildVersionCommand(
"C:\\\\bin\\\\opencode.ps1",
"win32"
)
// when building command
// then should use PowerShell
expect(command).toEqual([
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"C:\\\\bin\\\\opencode.ps1",
"--version",
])
})
it("builds direct command for non-ps1 binaries", () => {
// given an exe on Windows and a binary on linux
const winCommand = opencode.buildVersionCommand(
"C:\\\\bin\\\\opencode.exe",
"win32"
)
const linuxCommand = opencode.buildVersionCommand("opencode", "linux")
// when building commands
// then should execute directly
expect(winCommand).toEqual(["C:\\\\bin\\\\opencode.exe", "--version"])
expect(linuxCommand).toEqual(["opencode", "--version"])
})
})
describe("getOpenCodeInfo", () => {
it("returns installed: false when binary not found", async () => {
// given no opencode binary
const spy = spyOn(opencode, "findOpenCodeBinary").mockResolvedValue(null)
// when getting info
const info = await opencode.getOpenCodeInfo()
// then should indicate not installed
expect(info.installed).toBe(false)
expect(info.version).toBeNull()
expect(info.path).toBeNull()
expect(info.binary).toBeNull()
spy.mockRestore()
})
})
describe("checkOpenCodeInstallation", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns fail when not installed", async () => {
// given opencode not installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: false,
version: null,
path: null,
binary: null,
})
// when checking installation
const result = await opencode.checkOpenCodeInstallation()
// then should fail with installation hint
expect(result.status).toBe("fail")
expect(result.message).toContain("not installed")
expect(result.details).toBeDefined()
expect(result.details?.some((d) => d.includes("opencode.ai"))).toBe(true)
})
it("returns warn when version below minimum", async () => {
// given old version installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: true,
version: "1.0.100",
path: "/usr/local/bin/opencode",
binary: "opencode",
})
// when checking installation
const result = await opencode.checkOpenCodeInstallation()
// then should warn about old version
expect(result.status).toBe("warn")
expect(result.message).toContain("below minimum")
expect(result.details?.some((d) => d.includes(MIN_OPENCODE_VERSION))).toBe(true)
})
it("returns pass when properly installed", async () => {
// given current version installed
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
installed: true,
version: "1.0.200",
path: "/usr/local/bin/opencode",
binary: "opencode",
})
// when checking installation
const result = await opencode.checkOpenCodeInstallation()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("1.0.200")
})
})
describe("getOpenCodeCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = opencode.getOpenCodeCheckDefinition()
// then should have required properties
expect(def.id).toBe("opencode-installation")
expect(def.category).toBe("installation")
expect(def.critical).toBe(true)
expect(typeof def.check).toBe("function")
})
})
describe("getDesktopAppPaths", () => {
it("returns macOS desktop app paths for darwin platform", () => {
// given darwin platform
const platform: NodeJS.Platform = "darwin"
// when getting desktop paths
const paths = opencode.getDesktopAppPaths(platform)
// then should include macOS app bundle paths with correct binary name
expect(paths).toContain("/Applications/OpenCode.app/Contents/MacOS/OpenCode")
expect(paths.some((p) => p.includes("Applications/OpenCode.app"))).toBe(true)
})
it("returns Windows desktop app paths for win32 platform when env vars set", () => {
// given win32 platform with env vars set
const platform: NodeJS.Platform = "win32"
const originalProgramFiles = process.env.ProgramFiles
const originalLocalAppData = process.env.LOCALAPPDATA
process.env.ProgramFiles = "C:\\Program Files"
process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"
// when getting desktop paths
const paths = opencode.getDesktopAppPaths(platform)
// then should include Windows program paths with correct binary name
expect(paths.some((p) => p.includes("Program Files"))).toBe(true)
expect(paths.some((p) => p.endsWith("OpenCode.exe"))).toBe(true)
expect(paths.every((p) => p.startsWith("C:\\"))).toBe(true)
// cleanup
process.env.ProgramFiles = originalProgramFiles
process.env.LOCALAPPDATA = originalLocalAppData
})
it("returns empty array for win32 when all env vars undefined", () => {
// given win32 platform with no env vars
const platform: NodeJS.Platform = "win32"
const originalProgramFiles = process.env.ProgramFiles
const originalLocalAppData = process.env.LOCALAPPDATA
delete process.env.ProgramFiles
delete process.env.LOCALAPPDATA
// when getting desktop paths
const paths = opencode.getDesktopAppPaths(platform)
// then should return empty array (no relative paths)
expect(paths).toEqual([])
// cleanup
process.env.ProgramFiles = originalProgramFiles
process.env.LOCALAPPDATA = originalLocalAppData
})
it("returns Linux desktop app paths for linux platform", () => {
// given linux platform
const platform: NodeJS.Platform = "linux"
// when getting desktop paths
const paths = opencode.getDesktopAppPaths(platform)
// then should include verified Linux installation paths
expect(paths).toContain("/usr/bin/opencode")
expect(paths).toContain("/usr/lib/opencode/opencode")
expect(paths.some((p) => p.includes("AppImage"))).toBe(true)
})
it("returns empty array for unsupported platforms", () => {
// given unsupported platform
const platform = "freebsd" as NodeJS.Platform
// when getting desktop paths
const paths = opencode.getDesktopAppPaths(platform)
// then should return empty array
expect(paths).toEqual([])
})
})
describe("findOpenCodeBinary with desktop fallback", () => {
it("falls back to desktop paths when PATH binary not found", async () => {
// given no binary in PATH but desktop app exists
const existsSyncMock = (p: string) =>
p === "/Applications/OpenCode.app/Contents/MacOS/OpenCode"
// when finding binary with mocked filesystem
const result = await opencode.findDesktopBinary("darwin", existsSyncMock)
// then should find desktop app
expect(result).not.toBeNull()
expect(result?.path).toBe("/Applications/OpenCode.app/Contents/MacOS/OpenCode")
})
it("returns null when no desktop binary found", async () => {
// given no binary exists
const existsSyncMock = () => false
// when finding binary
const result = await opencode.findDesktopBinary("darwin", existsSyncMock)
// then should return null
expect(result).toBeNull()
})
})
})

View File

@@ -0,0 +1,227 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants"
const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"]
export function getDesktopAppPaths(platform: NodeJS.Platform): string[] {
const home = homedir()
switch (platform) {
case "darwin":
return [
"/Applications/OpenCode.app/Contents/MacOS/OpenCode",
join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"),
]
case "win32": {
const programFiles = process.env.ProgramFiles
const localAppData = process.env.LOCALAPPDATA
const paths: string[] = []
if (programFiles) {
paths.push(join(programFiles, "OpenCode", "OpenCode.exe"))
}
if (localAppData) {
paths.push(join(localAppData, "OpenCode", "OpenCode.exe"))
}
return paths
}
case "linux":
return [
"/usr/bin/opencode",
"/usr/lib/opencode/opencode",
join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"),
join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"),
]
default:
return []
}
}
export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" {
return platform === "win32" ? "where" : "which"
}
export function parseBinaryPaths(output: string): string[] {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
export function selectBinaryPath(
paths: string[],
platform: NodeJS.Platform
): string | null {
if (paths.length === 0) return null
if (platform !== "win32") return paths[0]
const normalized = paths.map((path) => path.toLowerCase())
for (const ext of WINDOWS_EXECUTABLE_EXTS) {
const index = normalized.findIndex((path) => path.endsWith(ext))
if (index !== -1) return paths[index]
}
return paths[0]
}
export function buildVersionCommand(
binaryPath: string,
platform: NodeJS.Platform
): string[] {
if (
platform === "win32" &&
binaryPath.toLowerCase().endsWith(".ps1")
) {
return [
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
binaryPath,
"--version",
]
}
return [binaryPath, "--version"]
}
export function findDesktopBinary(
platform: NodeJS.Platform = process.platform,
checkExists: (path: string) => boolean = existsSync
): { binary: string; path: string } | null {
const desktopPaths = getDesktopAppPaths(platform)
for (const desktopPath of desktopPaths) {
if (checkExists(desktopPath)) {
return { binary: "opencode", path: desktopPath }
}
}
return null
}
export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> {
for (const binary of OPENCODE_BINARIES) {
try {
const path = Bun.which(binary)
if (path) {
return { binary, path }
}
} catch {
continue
}
}
const desktopResult = findDesktopBinary()
if (desktopResult) {
return desktopResult
}
return null
}
export async function getOpenCodeVersion(
binaryPath: string,
platform: NodeJS.Platform = process.platform
): Promise<string | null> {
try {
const command = buildVersionCommand(binaryPath, platform)
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return output.trim()
}
} catch {
return null
}
return null
}
export function compareVersions(current: string, minimum: string): boolean {
const parseVersion = (v: string): number[] => {
const cleaned = v.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
}
const curr = parseVersion(current)
const min = parseVersion(minimum)
for (let i = 0; i < Math.max(curr.length, min.length); i++) {
const c = curr[i] ?? 0
const m = min[i] ?? 0
if (c > m) return true
if (c < m) return false
}
return true
}
export async function getOpenCodeInfo(): Promise<OpenCodeInfo> {
const binaryInfo = await findOpenCodeBinary()
if (!binaryInfo) {
return {
installed: false,
version: null,
path: null,
binary: null,
}
}
const version = await getOpenCodeVersion(binaryInfo.path ?? binaryInfo.binary)
return {
installed: true,
version,
path: binaryInfo.path,
binary: binaryInfo.binary as "opencode" | "opencode-desktop",
}
}
export async function checkOpenCodeInstallation(): Promise<CheckResult> {
const info = await getOpenCodeInfo()
if (!info.installed) {
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "fail",
message: "OpenCode is not installed",
details: [
"Visit: https://opencode.ai/docs for installation instructions",
"Run: npm install -g opencode",
],
}
}
if (info.version && !compareVersions(info.version, MIN_OPENCODE_VERSION)) {
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "warn",
message: `Version ${info.version} is below minimum ${MIN_OPENCODE_VERSION}`,
details: [
`Current: ${info.version}`,
`Required: >= ${MIN_OPENCODE_VERSION}`,
"Run: npm update -g opencode",
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
status: "pass",
message: info.version ?? "installed",
details: info.path ? [`Path: ${info.path}`] : undefined,
}
}
export function getOpenCodeCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.OPENCODE_INSTALLATION,
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
category: "installation",
check: checkOpenCodeInstallation,
critical: true,
}
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as plugin from "./plugin"
describe("plugin check", () => {
describe("getPluginInfo", () => {
it("returns registered: false when config not found", () => {
// given no config file exists
// when getting plugin info
// then should indicate not registered
const info = plugin.getPluginInfo()
expect(typeof info.registered).toBe("boolean")
expect(typeof info.isPinned).toBe("boolean")
})
})
describe("checkPluginRegistration", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns fail when config file not found", async () => {
// given no config file
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: false,
configPath: null,
entry: null,
isPinned: false,
pinnedVersion: null,
})
// when checking registration
const result = await plugin.checkPluginRegistration()
// then should fail with hint
expect(result.status).toBe("fail")
expect(result.message).toContain("not found")
})
it("returns fail when plugin not registered", async () => {
// given config exists but plugin not registered
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: false,
configPath: "/home/user/.config/opencode/opencode.json",
entry: null,
isPinned: false,
pinnedVersion: null,
})
// when checking registration
const result = await plugin.checkPluginRegistration()
// then should fail
expect(result.status).toBe("fail")
expect(result.message).toContain("not registered")
})
it("returns pass when plugin registered", async () => {
// given plugin registered
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: true,
configPath: "/home/user/.config/opencode/opencode.json",
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
})
// when checking registration
const result = await plugin.checkPluginRegistration()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("Registered")
})
it("indicates pinned version when applicable", async () => {
// given plugin pinned to version
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
registered: true,
configPath: "/home/user/.config/opencode/opencode.json",
entry: "oh-my-opencode@2.7.0",
isPinned: true,
pinnedVersion: "2.7.0",
})
// when checking registration
const result = await plugin.checkPluginRegistration()
// then should show pinned version
expect(result.status).toBe("pass")
expect(result.message).toContain("pinned")
expect(result.message).toContain("2.7.0")
})
})
describe("getPluginCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = plugin.getPluginCheckDefinition()
// then should have required properties
expect(def.id).toBe("plugin-registration")
expect(def.category).toBe("installation")
expect(def.critical).toBe(true)
})
})
})

View File

@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from "node:fs"
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
if (existsSync(paths.configJsonc)) {
return { path: paths.configJsonc, format: "jsonc" }
}
if (existsSync(paths.configJson)) {
return { path: paths.configJson, format: "json" }
}
return null
}
function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean; version: string | null } | null {
for (const plugin of plugins) {
if (plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`)) {
const isPinned = plugin.includes("@")
const version = isPinned ? plugin.split("@")[1] : null
return { entry: plugin, isPinned, version }
}
if (plugin.startsWith("file://") && plugin.includes(PACKAGE_NAME)) {
return { entry: plugin, isPinned: false, version: "local-dev" }
}
}
return null
}
export function getPluginInfo(): PluginInfo {
const configInfo = detectConfigPath()
if (!configInfo) {
return {
registered: false,
configPath: null,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
try {
const content = readFileSync(configInfo.path, "utf-8")
const config = parseJsonc<{ plugin?: string[] }>(content)
const plugins = config.plugin ?? []
const pluginEntry = findPluginEntry(plugins)
if (!pluginEntry) {
return {
registered: false,
configPath: configInfo.path,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
return {
registered: true,
configPath: configInfo.path,
entry: pluginEntry.entry,
isPinned: pluginEntry.isPinned,
pinnedVersion: pluginEntry.version,
}
} catch {
return {
registered: false,
configPath: configInfo.path,
entry: null,
isPinned: false,
pinnedVersion: null,
}
}
}
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: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
],
}
}
if (!info.registered) {
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "fail",
message: "Plugin not registered in config",
details: [
"Run: bunx oh-my-opencode install",
`Config: ${info.configPath}`,
],
}
}
const message = info.isPinned
? `Registered (pinned: ${info.pinnedVersion})`
: "Registered"
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "pass",
message,
details: [`Config: ${info.configPath}`],
}
}
export function getPluginCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.PLUGIN_REGISTRATION,
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
category: "installation",
check: checkPluginRegistration,
critical: true,
}
}

View File

@@ -1,144 +0,0 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { OPENCODE_BINARIES } from "../constants"
const WINDOWS_EXECUTABLE_EXTS = [".exe", ".cmd", ".bat", ".ps1"]
export interface OpenCodeBinaryInfo {
binary: string
path: string
}
export function getDesktopAppPaths(platform: NodeJS.Platform): string[] {
const home = homedir()
switch (platform) {
case "darwin":
return [
"/Applications/OpenCode.app/Contents/MacOS/OpenCode",
join(home, "Applications", "OpenCode.app", "Contents", "MacOS", "OpenCode"),
]
case "win32": {
const programFiles = process.env.ProgramFiles
const localAppData = process.env.LOCALAPPDATA
const paths: string[] = []
if (programFiles) {
paths.push(join(programFiles, "OpenCode", "OpenCode.exe"))
}
if (localAppData) {
paths.push(join(localAppData, "OpenCode", "OpenCode.exe"))
}
return paths
}
case "linux":
return [
"/usr/bin/opencode",
"/usr/lib/opencode/opencode",
join(home, "Applications", "opencode-desktop-linux-x86_64.AppImage"),
join(home, "Applications", "opencode-desktop-linux-aarch64.AppImage"),
]
default:
return []
}
}
export function getBinaryLookupCommand(platform: NodeJS.Platform): "which" | "where" {
return platform === "win32" ? "where" : "which"
}
export function parseBinaryPaths(output: string): string[] {
return output
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line.length > 0)
}
export function selectBinaryPath(paths: string[], platform: NodeJS.Platform): string | null {
if (paths.length === 0) return null
if (platform !== "win32") return paths[0] ?? null
const normalizedPaths = paths.map((path) => path.toLowerCase())
for (const extension of WINDOWS_EXECUTABLE_EXTS) {
const pathIndex = normalizedPaths.findIndex((path) => path.endsWith(extension))
if (pathIndex !== -1) {
return paths[pathIndex] ?? null
}
}
return paths[0] ?? null
}
export function buildVersionCommand(binaryPath: string, platform: NodeJS.Platform): string[] {
if (platform === "win32" && binaryPath.toLowerCase().endsWith(".ps1")) {
return ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", binaryPath, "--version"]
}
return [binaryPath, "--version"]
}
export function findDesktopBinary(
platform: NodeJS.Platform = process.platform,
checkExists: (path: string) => boolean = existsSync
): OpenCodeBinaryInfo | null {
for (const desktopPath of getDesktopAppPaths(platform)) {
if (checkExists(desktopPath)) {
return { binary: "opencode", path: desktopPath }
}
}
return null
}
export async function findOpenCodeBinary(): Promise<OpenCodeBinaryInfo | null> {
for (const binary of OPENCODE_BINARIES) {
const path = Bun.which(binary)
if (path) {
return { binary, path }
}
}
return findDesktopBinary()
}
export async function getOpenCodeVersion(
binaryPath: string,
platform: NodeJS.Platform = process.platform
): Promise<string | null> {
try {
const command = buildVersionCommand(binaryPath, platform)
const processResult = Bun.spawn(command, { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited
if (processResult.exitCode !== 0) return null
return output.trim() || null
} catch {
return null
}
}
export function compareVersions(current: string, minimum: string): boolean {
const parseVersion = (version: string): number[] =>
version
.replace(/^v/, "")
.split("-")[0]
.split(".")
.map((part) => Number.parseInt(part, 10) || 0)
const currentParts = parseVersion(current)
const minimumParts = parseVersion(minimum)
const length = Math.max(currentParts.length, minimumParts.length)
for (let index = 0; index < length; index++) {
const currentPart = currentParts[index] ?? 0
const minimumPart = minimumParts[index] ?? 0
if (currentPart > minimumPart) return true
if (currentPart < minimumPart) return false
}
return true
}

View File

@@ -1,79 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
import { extractChannel } from "../../../hooks/auto-update-checker"
import { PACKAGE_NAME } from "../constants"
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
interface PackageJsonShape {
version?: string
dependencies?: Record<string, string>
}
export interface LoadedVersionInfo {
cacheDir: string
cachePackagePath: string
installedPackagePath: string
expectedVersion: string | null
loadedVersion: string | null
}
function getPlatformDefaultCacheDir(platform: NodeJS.Platform = process.platform): string {
if (platform === "darwin") return join(homedir(), "Library", "Caches")
if (platform === "win32") return process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local")
return join(homedir(), ".cache")
}
function resolveOpenCodeCacheDir(): string {
const xdgCacheHome = process.env.XDG_CACHE_HOME
if (xdgCacheHome) return join(xdgCacheHome, "opencode")
const fromShared = getOpenCodeCacheDir()
const platformDefault = join(getPlatformDefaultCacheDir(), "opencode")
if (existsSync(fromShared) || !existsSync(platformDefault)) return fromShared
return platformDefault
}
function readPackageJson(filePath: string): PackageJsonShape | null {
if (!existsSync(filePath)) return null
try {
const content = readFileSync(filePath, "utf-8")
return parseJsonc<PackageJsonShape>(content)
} catch {
return null
}
}
function normalizeVersion(value: string | undefined): string | null {
if (!value) return null
const match = value.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)
return match?.[0] ?? null
}
export function getLoadedPluginVersion(): LoadedVersionInfo {
const cacheDir = resolveOpenCodeCacheDir()
const cachePackagePath = join(cacheDir, "package.json")
const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
const cachePackage = readPackageJson(cachePackagePath)
const installedPackage = readPackageJson(installedPackagePath)
const expectedVersion = normalizeVersion(cachePackage?.dependencies?.[PACKAGE_NAME])
const loadedVersion = normalizeVersion(installedPackage?.version)
return {
cacheDir,
cachePackagePath,
installedPackagePath,
expectedVersion,
loadedVersion,
}
}
export async function getLatestPluginVersion(currentVersion: string | null): Promise<string | null> {
const channel = extractChannel(currentVersion)
return getLatestVersion(channel)
}

View File

@@ -1,95 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import { PACKAGE_NAME } from "../constants"
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
export interface PluginInfo {
registered: boolean
configPath: string | null
entry: string | null
isPinned: boolean
pinnedVersion: string | null
isLocalDev: boolean
}
interface OpenCodeConfigShape {
plugin?: string[]
}
function detectConfigPath(): string | null {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
if (existsSync(paths.configJsonc)) return paths.configJsonc
if (existsSync(paths.configJson)) return paths.configJson
return null
}
function parsePluginVersion(entry: string): string | null {
if (!entry.startsWith(`${PACKAGE_NAME}@`)) return null
const value = entry.slice(PACKAGE_NAME.length + 1)
if (!value || value === "latest") return null
return value
}
function findPluginEntry(entries: string[]): { entry: string; isLocalDev: boolean } | null {
for (const entry of entries) {
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
return { entry, isLocalDev: false }
}
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
return { entry, isLocalDev: true }
}
}
return null
}
export function getPluginInfo(): PluginInfo {
const configPath = detectConfigPath()
if (!configPath) {
return {
registered: false,
configPath: null,
entry: null,
isPinned: false,
pinnedVersion: null,
isLocalDev: false,
}
}
try {
const content = readFileSync(configPath, "utf-8")
const parsedConfig = parseJsonc<OpenCodeConfigShape>(content)
const pluginEntry = findPluginEntry(parsedConfig.plugin ?? [])
if (!pluginEntry) {
return {
registered: false,
configPath,
entry: null,
isPinned: false,
pinnedVersion: null,
isLocalDev: false,
}
}
const pinnedVersion = parsePluginVersion(pluginEntry.entry)
return {
registered: true,
configPath,
entry: pluginEntry.entry,
isPinned: pinnedVersion !== null,
pinnedVersion,
isLocalDev: pluginEntry.isLocalDev,
}
} catch {
return {
registered: false,
configPath,
entry: null,
isPinned: false,
pinnedVersion: null,
isLocalDev: false,
}
}
}
export { detectConfigPath, findPluginEntry }

View File

@@ -1,129 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import { MIN_OPENCODE_VERSION, CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue, SystemInfo } from "../types"
import { findOpenCodeBinary, getOpenCodeVersion, compareVersions } from "./system-binary"
import { getPluginInfo } from "./system-plugin"
import { getLatestPluginVersion, getLoadedPluginVersion } from "./system-loaded-version"
import { parseJsonc } from "../../../shared"
function isConfigValid(configPath: string | null): boolean {
if (!configPath) return true
if (!existsSync(configPath)) return false
try {
parseJsonc<Record<string, unknown>>(readFileSync(configPath, "utf-8"))
return true
} catch {
return false
}
}
function getResultStatus(issues: DoctorIssue[]): CheckResult["status"] {
if (issues.some((issue) => issue.severity === "error")) return "fail"
if (issues.some((issue) => issue.severity === "warning")) return "warn"
return "pass"
}
function buildMessage(status: CheckResult["status"], issues: DoctorIssue[]): string {
if (status === "pass") return "System checks passed"
if (status === "fail") return `${issues.length} system issue(s) detected`
return `${issues.length} system warning(s) detected`
}
export async function gatherSystemInfo(): Promise<SystemInfo> {
const [binaryInfo, pluginInfo] = await Promise.all([findOpenCodeBinary(), Promise.resolve(getPluginInfo())])
const loadedInfo = getLoadedPluginVersion()
const opencodeVersion = binaryInfo ? await getOpenCodeVersion(binaryInfo.path) : null
const pluginVersion = pluginInfo.pinnedVersion ?? loadedInfo.expectedVersion
return {
opencodeVersion,
opencodePath: binaryInfo?.path ?? null,
pluginVersion,
loadedVersion: loadedInfo.loadedVersion,
bunVersion: Bun.version,
configPath: pluginInfo.configPath,
configValid: isConfigValid(pluginInfo.configPath),
isLocalDev: pluginInfo.isLocalDev,
}
}
export async function checkSystem(): Promise<CheckResult> {
const [systemInfo, pluginInfo] = await Promise.all([gatherSystemInfo(), Promise.resolve(getPluginInfo())])
const loadedInfo = getLoadedPluginVersion()
const latestVersion = await getLatestPluginVersion(systemInfo.loadedVersion)
const issues: DoctorIssue[] = []
if (!systemInfo.opencodePath) {
issues.push({
title: "OpenCode binary not found",
description: "Install OpenCode CLI or desktop and ensure the binary is available.",
fix: "Install from https://opencode.ai/docs",
severity: "error",
affects: ["doctor", "run"],
})
}
if (
systemInfo.opencodeVersion &&
!compareVersions(systemInfo.opencodeVersion, MIN_OPENCODE_VERSION)
) {
issues.push({
title: "OpenCode version below minimum",
description: `Detected ${systemInfo.opencodeVersion}; required >= ${MIN_OPENCODE_VERSION}.`,
fix: "Update OpenCode to the latest stable release",
severity: "warning",
affects: ["tooling", "doctor"],
})
}
if (!pluginInfo.registered) {
issues.push({
title: "oh-my-opencode is not registered",
description: "Plugin entry is missing from OpenCode configuration.",
fix: "Run: bunx oh-my-opencode install",
severity: "error",
affects: ["all agents"],
})
}
if (loadedInfo.expectedVersion && loadedInfo.loadedVersion && loadedInfo.expectedVersion !== loadedInfo.loadedVersion) {
issues.push({
title: "Loaded plugin version mismatch",
description: `Cache expects ${loadedInfo.expectedVersion} but loaded ${loadedInfo.loadedVersion}.`,
fix: "Reinstall plugin dependencies in OpenCode cache",
severity: "warning",
affects: ["plugin loading"],
})
}
if (
systemInfo.loadedVersion &&
latestVersion &&
!compareVersions(systemInfo.loadedVersion, latestVersion)
) {
issues.push({
title: "Loaded plugin is outdated",
description: `Loaded ${systemInfo.loadedVersion}, latest ${latestVersion}.`,
fix: "Update: cd ~/.config/opencode && bun update oh-my-opencode",
severity: "warning",
affects: ["plugin features"],
})
}
const status = getResultStatus(issues)
return {
name: CHECK_NAMES[CHECK_IDS.SYSTEM],
status,
message: buildMessage(status, issues),
details: [
systemInfo.opencodeVersion ? `OpenCode: ${systemInfo.opencodeVersion}` : "OpenCode: not detected",
`Plugin expected: ${systemInfo.pluginVersion ?? "unknown"}`,
`Plugin loaded: ${systemInfo.loadedVersion ?? "unknown"}`,
`Bun: ${systemInfo.bunVersion ?? "unknown"}`,
],
issues,
}
}

View File

@@ -1,105 +0,0 @@
export interface GhCliInfo {
installed: boolean
version: string | null
path: string | null
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
const binaryPath = Bun.which(binary)
return { exists: Boolean(binaryPath), path: binaryPath ?? null }
} catch {
return { exists: false, path: null }
}
}
async function getGhVersion(): Promise<string | null> {
try {
const processResult = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(processResult.stdout).text()
await processResult.exited
if (processResult.exitCode !== 0) return null
const matchedVersion = output.match(/gh version (\S+)/)
return matchedVersion?.[1] ?? output.trim().split("\n")[0] ?? null
} catch {
return null
}
}
async function getGhAuthStatus(): Promise<{
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}> {
try {
const processResult = Bun.spawn(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
})
const stdout = await new Response(processResult.stdout).text()
const stderr = await new Response(processResult.stderr).text()
await processResult.exited
const output = stderr || stdout
if (processResult.exitCode === 0) {
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
return {
authenticated: true,
username: usernameMatch?.[1]?.replace(/[()]/g, "") ?? null,
scopes: scopesMatch?.[1]?.split(/,\s*/).map((scope) => scope.trim()).filter(Boolean) ?? [],
error: null,
}
}
const errorMatch = output.match(/error[:\s]+(.+)/i)
return {
authenticated: false,
username: null,
scopes: [],
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
}
} catch (error) {
return {
authenticated: false,
username: null,
scopes: [],
error: error instanceof Error ? error.message : "Failed to check auth status",
}
}
}
export async function getGhCliInfo(): Promise<GhCliInfo> {
const binaryStatus = await checkBinaryExists("gh")
if (!binaryStatus.exists) {
return {
installed: false,
version: null,
path: null,
authenticated: false,
username: null,
scopes: [],
error: null,
}
}
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
return {
installed: true,
version,
path: binaryStatus.path,
authenticated: authStatus.authenticated,
username: authStatus.username,
scopes: authStatus.scopes,
error: authStatus.error,
}
}

View File

@@ -1,25 +0,0 @@
import type { LspServerInfo } from "../types"
import { isServerInstalled } from "../../../tools/lsp/config"
const DEFAULT_LSP_SERVERS: Array<{ id: string; binary: string; extensions: string[] }> = [
{ id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] },
{ id: "pyright", binary: "pyright-langserver", extensions: [".py"] },
{ id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] },
{ id: "gopls", binary: "gopls", extensions: [".go"] },
]
export function getLspServersInfo(): LspServerInfo[] {
return DEFAULT_LSP_SERVERS.map((server) => ({
id: server.id,
installed: isServerInstalled([server.binary]),
extensions: server.extensions,
source: "builtin",
}))
}
export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } {
return {
installed: servers.filter((server) => server.installed).length,
total: servers.length,
}
}

View File

@@ -1,62 +0,0 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { McpServerInfo } from "../types"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
interface McpConfigShape {
mcpServers?: Record<string, unknown>
}
function getMcpConfigPaths(): string[] {
return [
join(homedir(), ".claude", ".mcp.json"),
join(process.cwd(), ".mcp.json"),
join(process.cwd(), ".claude", ".mcp.json"),
]
}
function loadUserMcpConfig(): Record<string, unknown> {
const servers: Record<string, unknown> = {}
for (const configPath of getMcpConfigPaths()) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<McpConfigShape>(content)
if (config.mcpServers) {
Object.assign(servers, config.mcpServers)
}
} catch {
continue
}
}
return servers
}
export function getBuiltinMcpInfo(): McpServerInfo[] {
return BUILTIN_MCP_SERVERS.map((serverId) => ({
id: serverId,
type: "builtin",
enabled: true,
valid: true,
}))
}
export function getUserMcpInfo(): McpServerInfo[] {
return Object.entries(loadUserMcpConfig()).map(([serverId, value]) => {
const valid = typeof value === "object" && value !== null
return {
id: serverId,
type: "user",
enabled: true,
valid,
error: valid ? undefined : "Invalid configuration format",
}
})
}

View File

@@ -1,118 +0,0 @@
import { checkAstGrepCli, checkAstGrepNapi, checkCommentChecker } from "./dependencies"
import { getGhCliInfo } from "./tools-gh"
import { getLspServerStats, getLspServersInfo } from "./tools-lsp"
import { getBuiltinMcpInfo, getUserMcpInfo } from "./tools-mcp"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue, ToolsSummary } from "../types"
export async function gatherToolsSummary(): Promise<ToolsSummary> {
const [astGrepCliInfo, astGrepNapiInfo, commentCheckerInfo, ghInfo] = await Promise.all([
checkAstGrepCli(),
checkAstGrepNapi(),
checkCommentChecker(),
getGhCliInfo(),
])
const lspServers = getLspServersInfo()
const lspStats = getLspServerStats(lspServers)
const builtinMcp = getBuiltinMcpInfo()
const userMcp = getUserMcpInfo()
return {
lspInstalled: lspStats.installed,
lspTotal: lspStats.total,
astGrepCli: astGrepCliInfo.installed,
astGrepNapi: astGrepNapiInfo.installed,
commentChecker: commentCheckerInfo.installed,
ghCli: {
installed: ghInfo.installed,
authenticated: ghInfo.authenticated,
username: ghInfo.username,
},
mcpBuiltin: builtinMcp.map((server) => server.id),
mcpUser: userMcp.map((server) => server.id),
}
}
function buildToolIssues(summary: ToolsSummary): DoctorIssue[] {
const issues: DoctorIssue[] = []
if (!summary.astGrepCli && !summary.astGrepNapi) {
issues.push({
title: "AST-Grep unavailable",
description: "Neither AST-Grep CLI nor NAPI backend is available.",
fix: "Install @ast-grep/cli globally or add @ast-grep/napi",
severity: "warning",
affects: ["ast_grep_search", "ast_grep_replace"],
})
}
if (!summary.commentChecker) {
issues.push({
title: "Comment checker unavailable",
description: "Comment checker binary is not installed.",
fix: "Install @code-yeongyu/comment-checker",
severity: "warning",
affects: ["comment-checker hook"],
})
}
if (summary.lspInstalled === 0) {
issues.push({
title: "No LSP servers detected",
description: "LSP-dependent tools will be limited until at least one server is installed.",
severity: "warning",
affects: ["lsp diagnostics", "rename", "references"],
})
}
if (!summary.ghCli.installed) {
issues.push({
title: "GitHub CLI missing",
description: "gh CLI is not installed.",
fix: "Install from https://cli.github.com/",
severity: "warning",
affects: ["GitHub automation"],
})
} else if (!summary.ghCli.authenticated) {
issues.push({
title: "GitHub CLI not authenticated",
description: "gh CLI is installed but not logged in.",
fix: "Run: gh auth login",
severity: "warning",
affects: ["GitHub automation"],
})
}
return issues
}
export async function checkTools(): Promise<CheckResult> {
const summary = await gatherToolsSummary()
const userMcpServers = getUserMcpInfo()
const invalidUserMcpServers = userMcpServers.filter((server) => !server.valid)
const issues = buildToolIssues(summary)
if (invalidUserMcpServers.length > 0) {
issues.push({
title: "Invalid MCP server configuration",
description: `${invalidUserMcpServers.length} user MCP server(s) have invalid config format.`,
severity: "warning",
affects: ["custom MCP tools"],
})
}
return {
name: CHECK_NAMES[CHECK_IDS.TOOLS],
status: issues.length === 0 ? "pass" : "warn",
message: issues.length === 0 ? "All tools checks passed" : `${issues.length} tools issue(s) detected`,
details: [
`AST-Grep: cli=${summary.astGrepCli ? "yes" : "no"}, napi=${summary.astGrepNapi ? "yes" : "no"}`,
`Comment checker: ${summary.commentChecker ? "yes" : "no"}`,
`LSP: ${summary.lspInstalled}/${summary.lspTotal}`,
`GH CLI: ${summary.ghCli.installed ? "installed" : "missing"}${summary.ghCli.authenticated ? " (authenticated)" : ""}`,
`MCP: builtin=${summary.mcpBuiltin.length}, user=${summary.mcpUser.length}`,
],
issues,
}
}

View File

@@ -0,0 +1,148 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as version from "./version"
describe("version check", () => {
describe("getVersionInfo", () => {
it("returns version check info structure", async () => {
// given
// when getting version info
const info = await version.getVersionInfo()
// then should have expected structure
expect(typeof info.isUpToDate).toBe("boolean")
expect(typeof info.isLocalDev).toBe("boolean")
expect(typeof info.isPinned).toBe("boolean")
})
})
describe("checkVersionStatus", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns pass when in local dev mode", async () => {
// given local dev mode
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "local-dev",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: true,
isPinned: false,
})
// when checking
const result = await version.checkVersionStatus()
// then should pass with dev message
expect(result.status).toBe("pass")
expect(result.message).toContain("local development")
})
it("returns pass when pinned", async () => {
// given pinned version
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: false,
isPinned: true,
})
// when checking
const result = await version.checkVersionStatus()
// then should pass with pinned message
expect(result.status).toBe("pass")
expect(result.message).toContain("Pinned")
})
it("returns warn when unable to determine version", async () => {
// given no version info
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: null,
latestVersion: "2.7.0",
isUpToDate: false,
isLocalDev: false,
isPinned: false,
})
// when checking
const result = await version.checkVersionStatus()
// then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("Unable to determine")
})
it("returns warn when network error", async () => {
// given network error
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: null,
isUpToDate: true,
isLocalDev: false,
isPinned: false,
})
// when checking
const result = await version.checkVersionStatus()
// then should warn
expect(result.status).toBe("warn")
expect(result.details?.some((d) => d.includes("network"))).toBe(true)
})
it("returns warn when update available", async () => {
// given update available
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.6.0",
latestVersion: "2.7.0",
isUpToDate: false,
isLocalDev: false,
isPinned: false,
})
// when checking
const result = await version.checkVersionStatus()
// then should warn with update info
expect(result.status).toBe("warn")
expect(result.message).toContain("Update available")
expect(result.message).toContain("2.6.0")
expect(result.message).toContain("2.7.0")
})
it("returns pass when up to date", async () => {
// given up to date
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
currentVersion: "2.7.0",
latestVersion: "2.7.0",
isUpToDate: true,
isLocalDev: false,
isPinned: false,
})
// when checking
const result = await version.checkVersionStatus()
// then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("Up to date")
})
})
describe("getVersionCheckDefinition", () => {
it("returns valid check definition", () => {
// given
// when getting definition
const def = version.getVersionCheckDefinition()
// then should have required properties
expect(def.id).toBe("version-status")
expect(def.category).toBe("updates")
expect(def.critical).toBe(false)
})
})
})

View File

@@ -0,0 +1,135 @@
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import {
getCachedVersion,
getLatestVersion,
isLocalDevMode,
findPluginEntry,
} from "../../../hooks/auto-update-checker/checker"
function compareVersions(current: string, latest: string): boolean {
const parseVersion = (v: string): number[] => {
const cleaned = v.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
}
const curr = parseVersion(current)
const lat = parseVersion(latest)
for (let i = 0; i < Math.max(curr.length, lat.length); i++) {
const c = curr[i] ?? 0
const l = lat[i] ?? 0
if (c < l) return false
if (c > l) return true
}
return true
}
export async function getVersionInfo(): Promise<VersionCheckInfo> {
const cwd = process.cwd()
if (isLocalDevMode(cwd)) {
return {
currentVersion: "local-dev",
latestVersion: null,
isUpToDate: true,
isLocalDev: true,
isPinned: false,
}
}
const pluginInfo = findPluginEntry(cwd)
if (pluginInfo?.isPinned) {
return {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: true,
isLocalDev: false,
isPinned: true,
}
}
const currentVersion = getCachedVersion()
const { extractChannel } = await import("../../../hooks/auto-update-checker/index")
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
const latestVersion = await getLatestVersion(channel)
const isUpToDate =
!currentVersion ||
!latestVersion ||
compareVersions(currentVersion, latestVersion)
return {
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: false,
isPinned: false,
}
}
export async function checkVersionStatus(): Promise<CheckResult> {
const info = await getVersionInfo()
if (info.isLocalDev) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: "Running in local development mode",
details: ["Using file:// protocol from config"],
}
}
if (info.isPinned) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: `Pinned to version ${info.currentVersion}`,
details: ["Update check skipped for pinned versions"],
}
}
if (!info.currentVersion) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: "Unable to determine current version",
details: ["Run: bunx oh-my-opencode get-local-version"],
}
}
if (!info.latestVersion) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: `Current: ${info.currentVersion}`,
details: ["Unable to check for updates (network error)"],
}
}
if (!info.isUpToDate) {
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "warn",
message: `Update available: ${info.currentVersion} -> ${info.latestVersion}`,
details: ["Run: cd ~/.config/opencode && bun update oh-my-opencode"],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
status: "pass",
message: `Up to date (${info.currentVersion})`,
details: info.latestVersion ? [`Latest: ${info.latestVersion}`] : undefined,
}
}
export function getVersionCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.VERSION_STATUS,
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
category: "updates",
check: checkVersionStatus,
critical: false,
}
}

View File

@@ -18,17 +18,50 @@ export const STATUS_COLORS = {
} as const
export const CHECK_IDS = {
SYSTEM: "system",
CONFIG: "config",
TOOLS: "tools",
MODELS: "models",
OPENCODE_INSTALLATION: "opencode-installation",
PLUGIN_REGISTRATION: "plugin-registration",
CONFIG_VALIDATION: "config-validation",
MODEL_RESOLUTION: "model-resolution",
AUTH_ANTHROPIC: "auth-anthropic",
AUTH_OPENAI: "auth-openai",
AUTH_GOOGLE: "auth-google",
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
DEP_COMMENT_CHECKER: "dep-comment-checker",
GH_CLI: "gh-cli",
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
VERSION_STATUS: "version-status",
} as const
export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.SYSTEM]: "System",
[CHECK_IDS.CONFIG]: "Configuration",
[CHECK_IDS.TOOLS]: "Tools",
[CHECK_IDS.MODELS]: "Models",
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
[CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution",
[CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth",
[CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth",
[CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth",
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
[CHECK_IDS.GH_CLI]: "GitHub CLI",
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
[CHECK_IDS.VERSION_STATUS]: "Version Status",
} as const
export const CATEGORY_NAMES: Record<string, string> = {
installation: "Installation",
configuration: "Configuration",
authentication: "Authentication",
dependencies: "Dependencies",
tools: "Tools & Servers",
updates: "Updates",
} as const
export const EXIT_CODES = {

View File

@@ -1,82 +0,0 @@
import { describe, expect, it } from "bun:test"
import { formatDefault } from "./format-default"
import { stripAnsi } from "./format-shared"
import type { DoctorResult } from "./types"
function createBaseResult(): DoctorResult {
return {
results: [
{ name: "System", status: "pass", message: "ok", issues: [] },
{ name: "Configuration", status: "pass", message: "ok", issues: [] },
],
systemInfo: {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.jsonc",
configValid: true,
isLocalDev: false,
},
tools: {
lspInstalled: 0,
lspTotal: 0,
astGrepCli: false,
astGrepNapi: false,
commentChecker: false,
ghCli: { installed: false, authenticated: false, username: null },
mcpBuiltin: [],
mcpUser: [],
},
summary: { total: 2, passed: 2, failed: 0, warnings: 0, skipped: 0, duration: 10 },
exitCode: 0,
}
}
describe("formatDefault", () => {
it("prints a single System OK line when no issues exist", () => {
//#given
const result = createBaseResult()
//#when
const output = stripAnsi(formatDefault(result))
//#then
expect(output).toContain("System OK (opencode 1.0.200")
expect(output).not.toContain("found:")
})
it("prints numbered issue list when issues exist", () => {
//#given
const result = createBaseResult()
result.results = [
{
name: "System",
status: "fail",
message: "failed",
issues: [
{
title: "OpenCode binary not found",
description: "Install OpenCode",
fix: "Install from https://opencode.ai/docs",
severity: "error",
},
{
title: "Loaded plugin is outdated",
description: "Loaded 3.0.0, latest 3.4.0",
severity: "warning",
},
],
},
]
//#when
const output = stripAnsi(formatDefault(result))
//#then
expect(output).toContain("2 issues found:")
expect(output).toContain("1. OpenCode binary not found")
expect(output).toContain("2. Loaded plugin is outdated")
})
})

View File

@@ -1,35 +0,0 @@
import color from "picocolors"
import type { DoctorResult } from "./types"
import { SYMBOLS } from "./constants"
import { formatHeader, formatIssue } from "./format-shared"
export function formatDefault(result: DoctorResult): string {
const lines: string[] = []
lines.push(formatHeader())
const allIssues = result.results.flatMap((r) => r.issues)
if (allIssues.length === 0) {
const opencodeVer = result.systemInfo.opencodeVersion ?? "unknown"
const pluginVer = result.systemInfo.pluginVersion ?? "unknown"
lines.push(
` ${color.green(SYMBOLS.check)} ${color.green(
`System OK (opencode ${opencodeVer} · oh-my-opencode ${pluginVer})`
)}`
)
} else {
const issueCount = allIssues.filter((i) => i.severity === "error").length
const warnCount = allIssues.filter((i) => i.severity === "warning").length
const totalStr = `${issueCount + warnCount} ${issueCount + warnCount === 1 ? "issue" : "issues"}`
lines.push(` ${color.yellow(SYMBOLS.warn)} ${totalStr} found:\n`)
allIssues.forEach((issue, index) => {
lines.push(formatIssue(issue, index + 1))
lines.push("")
})
}
return lines.join("\n")
}

View File

@@ -1,49 +0,0 @@
import color from "picocolors"
import type { CheckStatus, DoctorIssue } from "./types"
import { SYMBOLS, STATUS_COLORS } from "./constants"
export function formatStatusSymbol(status: CheckStatus): string {
const colorFn = STATUS_COLORS[status]
switch (status) {
case "pass":
return colorFn(SYMBOLS.check)
case "fail":
return colorFn(SYMBOLS.cross)
case "warn":
return colorFn(SYMBOLS.warn)
case "skip":
return colorFn(SYMBOLS.skip)
}
}
export function formatStatusMark(available: boolean): string {
return available ? color.green(SYMBOLS.check) : color.red(SYMBOLS.cross)
}
export function stripAnsi(str: string): string {
const ESC = String.fromCharCode(27)
const pattern = ESC + "\\[[0-9;]*m"
return str.replace(new RegExp(pattern, "g"), "")
}
export function formatHeader(): string {
return `\n${color.bgMagenta(color.white(" oMoMoMoMo Doctor "))}\n`
}
export function formatIssue(issue: DoctorIssue, index: number): string {
const lines: string[] = []
const severityColor = issue.severity === "error" ? color.red : color.yellow
lines.push(`${index}. ${severityColor(issue.title)}`)
lines.push(` ${color.dim(issue.description)}`)
if (issue.fix) {
lines.push(` ${color.cyan("Fix:")} ${color.dim(issue.fix)}`)
}
if (issue.affects && issue.affects.length > 0) {
lines.push(` ${color.cyan("Affects:")} ${color.dim(issue.affects.join(", "))}`)
}
return lines.join("\n")
}

View File

@@ -1,35 +0,0 @@
import color from "picocolors"
import type { DoctorResult } from "./types"
import { formatHeader, formatStatusMark } from "./format-shared"
export function formatStatus(result: DoctorResult): string {
const lines: string[] = []
lines.push(formatHeader())
const { systemInfo, tools } = result
const padding = " "
const opencodeVer = systemInfo.opencodeVersion ?? "unknown"
const pluginVer = systemInfo.pluginVersion ?? "unknown"
const bunVer = systemInfo.bunVersion ?? "unknown"
lines.push(` ${padding}System ${opencodeVer} · ${pluginVer} · Bun ${bunVer}`)
const configPath = systemInfo.configPath ?? "unknown"
const configStatus = systemInfo.configValid ? color.green("(valid)") : color.red("(invalid)")
lines.push(` ${padding}Config ${configPath} ${configStatus}`)
const lspText = `LSP ${tools.lspInstalled}/${tools.lspTotal}`
const astGrepMark = formatStatusMark(tools.astGrepCli)
const ghMark = formatStatusMark(tools.ghCli.installed && tools.ghCli.authenticated)
const ghUser = tools.ghCli.username ?? ""
lines.push(` ${padding}Tools ${lspText} · AST-Grep ${astGrepMark} · gh ${ghMark}${ghUser ? ` (${ghUser})` : ""}`)
const builtinCount = tools.mcpBuiltin.length
const userCount = tools.mcpUser.length
const builtinText = builtinCount > 0 ? tools.mcpBuiltin.join(" · ") : "none"
const userText = userCount > 0 ? `+ ${userCount} user` : ""
lines.push(` ${padding}MCPs ${builtinText} ${userText}`)
return lines.join("\n")
}

View File

@@ -1,79 +0,0 @@
import color from "picocolors"
import type { DoctorResult } from "./types"
import { formatHeader, formatStatusSymbol, formatIssue } from "./format-shared"
export function formatVerbose(result: DoctorResult): string {
const lines: string[] = []
lines.push(formatHeader())
const { systemInfo, tools, results, summary } = result
lines.push(`${color.bold("System Information")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
lines.push(` ${formatStatusSymbol("pass")} opencode ${systemInfo.opencodeVersion ?? "unknown"}`)
lines.push(` ${formatStatusSymbol("pass")} oh-my-opencode ${systemInfo.pluginVersion ?? "unknown"}`)
if (systemInfo.loadedVersion) {
lines.push(` ${formatStatusSymbol("pass")} loaded ${systemInfo.loadedVersion}`)
}
if (systemInfo.bunVersion) {
lines.push(` ${formatStatusSymbol("pass")} bun ${systemInfo.bunVersion}`)
}
lines.push(` ${formatStatusSymbol("pass")} path ${systemInfo.opencodePath ?? "unknown"}`)
if (systemInfo.isLocalDev) {
lines.push(` ${color.yellow("*")} ${color.dim("(local development mode)")}`)
}
lines.push("")
lines.push(`${color.bold("Configuration")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
const configStatus = systemInfo.configValid ? color.green("valid") : color.red("invalid")
lines.push(` ${formatStatusSymbol(systemInfo.configValid ? "pass" : "fail")} ${systemInfo.configPath ?? "unknown"} (${configStatus})`)
lines.push("")
lines.push(`${color.bold("Tools")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
lines.push(` ${formatStatusSymbol("pass")} LSP ${tools.lspInstalled}/${tools.lspTotal} installed`)
lines.push(` ${formatStatusSymbol(tools.astGrepCli ? "pass" : "fail")} ast-grep CLI ${tools.astGrepCli ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.astGrepNapi ? "pass" : "fail")} ast-grep napi ${tools.astGrepNapi ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.commentChecker ? "pass" : "fail")} comment-checker ${tools.commentChecker ? "installed" : "not found"}`)
lines.push(` ${formatStatusSymbol(tools.ghCli.installed && tools.ghCli.authenticated ? "pass" : "fail")} gh CLI ${tools.ghCli.installed ? "installed" : "not found"}${tools.ghCli.authenticated && tools.ghCli.username ? ` (${tools.ghCli.username})` : ""}`)
lines.push("")
lines.push(`${color.bold("MCPs")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
if (tools.mcpBuiltin.length === 0) {
lines.push(` ${color.dim("No built-in MCPs")}`)
} else {
for (const mcp of tools.mcpBuiltin) {
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
}
}
if (tools.mcpUser.length > 0) {
lines.push(` ${color.cyan("+")} ${tools.mcpUser.length} user MCP(s):`)
for (const mcp of tools.mcpUser) {
lines.push(` ${formatStatusSymbol("pass")} ${mcp}`)
}
}
lines.push("")
const allIssues = results.flatMap((r) => r.issues)
if (allIssues.length > 0) {
lines.push(`${color.bold("Issues")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
allIssues.forEach((issue, index) => {
lines.push(formatIssue(issue, index + 1))
lines.push("")
})
}
lines.push(`${color.bold("Summary")}`)
lines.push(`${color.dim("\u2500".repeat(40))}`)
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : `${summary.passed} passed`
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : `${summary.failed} failed`
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : `${summary.warnings} warnings`
lines.push(` ${passText}, ${failText}, ${warnText}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}

View File

@@ -1,126 +1,218 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import type { DoctorResult } from "./types"
function createDoctorResult(): DoctorResult {
return {
results: [
{ name: "System", status: "pass", message: "ok", issues: [] },
{ name: "Configuration", status: "warn", message: "warn", issues: [] },
],
systemInfo: {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.jsonc",
configValid: true,
isLocalDev: false,
},
tools: {
lspInstalled: 2,
lspTotal: 4,
astGrepCli: true,
astGrepNapi: false,
commentChecker: true,
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
mcpBuiltin: ["context7", "grep_app"],
mcpUser: ["custom"],
},
summary: {
total: 2,
passed: 1,
failed: 0,
warnings: 1,
skipped: 0,
duration: 12,
},
exitCode: 0,
}
}
import { describe, it, expect } from "bun:test"
import {
formatStatusSymbol,
formatCheckResult,
formatCategoryHeader,
formatSummary,
formatHeader,
formatFooter,
formatJsonOutput,
formatBox,
formatHelpSuggestions,
} from "./formatter"
import type { CheckResult, DoctorSummary, DoctorResult } from "./types"
describe("formatter", () => {
afterEach(() => {
mock.restore()
describe("formatStatusSymbol", () => {
it("returns green check for pass", () => {
const symbol = formatStatusSymbol("pass")
expect(symbol).toContain("\u2713")
})
it("returns red cross for fail", () => {
const symbol = formatStatusSymbol("fail")
expect(symbol).toContain("\u2717")
})
it("returns yellow warning for warn", () => {
const symbol = formatStatusSymbol("warn")
expect(symbol).toContain("\u26A0")
})
it("returns dim circle for skip", () => {
const symbol = formatStatusSymbol("skip")
expect(symbol).toContain("\u25CB")
})
})
describe("formatDoctorOutput", () => {
it("dispatches to default formatter for default mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?default=${Date.now()}`)
describe("formatCheckResult", () => {
it("includes name and message", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "All good",
}
//#when
const output = formatDoctorOutput(createDoctorResult(), "default")
const output = formatCheckResult(result, false)
//#then
expect(output).toBe("default-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(1)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("Test Check")
expect(output).toContain("All good")
})
it("dispatches to status formatter for status mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?status=${Date.now()}`)
it("includes details when verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1", "Detail 2"],
}
//#when
const output = formatDoctorOutput(createDoctorResult(), "status")
const output = formatCheckResult(result, true)
//#then
expect(output).toBe("status-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(1)
expect(formatVerboseMock).toHaveBeenCalledTimes(0)
expect(output).toContain("Detail 1")
expect(output).toContain("Detail 2")
})
it("dispatches to verbose formatter for verbose mode", async () => {
//#given
const formatDefaultMock = mock(() => "default-output")
const formatStatusMock = mock(() => "status-output")
const formatVerboseMock = mock(() => "verbose-output")
mock.module("./format-default", () => ({ formatDefault: formatDefaultMock }))
mock.module("./format-status", () => ({ formatStatus: formatStatusMock }))
mock.module("./format-verbose", () => ({ formatVerbose: formatVerboseMock }))
const { formatDoctorOutput } = await import(`./formatter?verbose=${Date.now()}`)
it("hides details when not verbose", () => {
const result: CheckResult = {
name: "Test Check",
status: "pass",
message: "OK",
details: ["Detail 1"],
}
//#when
const output = formatDoctorOutput(createDoctorResult(), "verbose")
const output = formatCheckResult(result, false)
//#then
expect(output).toBe("verbose-output")
expect(formatDefaultMock).toHaveBeenCalledTimes(0)
expect(formatStatusMock).toHaveBeenCalledTimes(0)
expect(formatVerboseMock).toHaveBeenCalledTimes(1)
expect(output).not.toContain("Detail 1")
})
})
describe("formatCategoryHeader", () => {
it("formats category name with styling", () => {
const header = formatCategoryHeader("installation")
expect(header).toContain("Installation")
})
})
describe("formatSummary", () => {
it("shows all counts", () => {
const summary: DoctorSummary = {
total: 10,
passed: 7,
failed: 1,
warnings: 2,
skipped: 0,
duration: 150,
}
const output = formatSummary(summary)
expect(output).toContain("7 passed")
expect(output).toContain("1 failed")
expect(output).toContain("2 warnings")
expect(output).toContain("10 checks")
expect(output).toContain("150ms")
})
})
describe("formatHeader", () => {
it("includes doctor branding", () => {
const header = formatHeader()
expect(header).toContain("Doctor")
})
})
describe("formatFooter", () => {
it("shows error message when failures", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 1,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("Issues detected")
})
it("shows warning message when warnings only", () => {
const summary: DoctorSummary = {
total: 5,
passed: 4,
failed: 0,
warnings: 1,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("warnings")
})
it("shows success message when all pass", () => {
const summary: DoctorSummary = {
total: 5,
passed: 5,
failed: 0,
warnings: 0,
skipped: 0,
duration: 100,
}
const footer = formatFooter(summary)
expect(footer).toContain("operational")
})
})
describe("formatJsonOutput", () => {
it("returns valid JSON payload", async () => {
//#given
const { formatJsonOutput } = await import(`./formatter?json=${Date.now()}`)
const result = createDoctorResult()
it("returns valid JSON", () => {
const result: DoctorResult = {
results: [{ name: "Test", status: "pass", message: "OK" }],
summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 },
exitCode: 0,
}
//#when
const output = formatJsonOutput(result)
const parsed = JSON.parse(output) as DoctorResult
const parsed = JSON.parse(output)
//#then
expect(parsed.summary.total).toBe(2)
expect(parsed.systemInfo.pluginVersion).toBe("3.4.0")
expect(parsed.tools.ghCli.username).toBe("yeongyu")
expect(parsed.results.length).toBe(1)
expect(parsed.summary.total).toBe(1)
expect(parsed.exitCode).toBe(0)
})
})
describe("formatBox", () => {
it("wraps content in box", () => {
const box = formatBox("Test content")
expect(box).toContain("Test content")
expect(box).toContain("\u2500")
})
it("includes title when provided", () => {
const box = formatBox("Content", "My Title")
expect(box).toContain("My Title")
})
})
describe("formatHelpSuggestions", () => {
it("extracts suggestions from failed checks", () => {
const results: CheckResult[] = [
{ name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] },
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions).toContain("Run: fix-command")
})
it("returns empty array when no failures", () => {
const results: CheckResult[] = [
{ name: "OK", status: "pass", message: "Good" },
]
const suggestions = formatHelpSuggestions(results)
expect(suggestions.length).toBe(0)
})
})
})

View File

@@ -1,19 +1,140 @@
import type { DoctorResult, DoctorMode } from "./types"
import { formatDefault } from "./format-default"
import { formatStatus } from "./format-status"
import { formatVerbose } from "./format-verbose"
import color from "picocolors"
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
export function formatDoctorOutput(result: DoctorResult, mode: DoctorMode): string {
switch (mode) {
case "default":
return formatDefault(result)
case "status":
return formatStatus(result)
case "verbose":
return formatVerbose(result)
export function formatStatusSymbol(status: CheckResult["status"]): string {
switch (status) {
case "pass":
return SYMBOLS.check
case "fail":
return SYMBOLS.cross
case "warn":
return SYMBOLS.warn
case "skip":
return SYMBOLS.skip
}
}
export function formatCheckResult(result: CheckResult, verbose: boolean): string {
const symbol = formatStatusSymbol(result.status)
const colorFn = STATUS_COLORS[result.status]
const name = colorFn(result.name)
const message = color.dim(result.message)
let line = ` ${symbol} ${name}`
if (result.message) {
line += ` ${SYMBOLS.arrow} ${message}`
}
if (verbose && result.details && result.details.length > 0) {
const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
line += "\n" + detailLines
}
return line
}
export function formatCategoryHeader(category: CheckCategory): string {
const name = CATEGORY_NAMES[category] || category
return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}`
}
export function formatSummary(summary: DoctorSummary): string {
const lines: string[] = []
lines.push(color.bold(color.white("Summary")))
lines.push(color.dim("\u2500".repeat(40)))
lines.push("")
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed")
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed")
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings")
const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : ""
const parts = [passText, failText, warnText]
if (skipText) parts.push(skipText)
lines.push(` ${parts.join(", ")}`)
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
return lines.join("\n")
}
export function formatHeader(): string {
return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n`
}
export function formatFooter(summary: DoctorSummary): string {
if (summary.failed > 0) {
return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n`
}
if (summary.warnings > 0) {
return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n`
}
return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n`
}
export function formatProgress(current: number, total: number, name: string): string {
const progress = color.dim(`[${current}/${total}]`)
return `${progress} Checking ${name}...`
}
export function formatJsonOutput(result: DoctorResult): string {
return JSON.stringify(result, null, 2)
}
export function formatDetails(details: string[]): string {
return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
}
function stripAnsi(str: string): string {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*m/g, "")
}
export function formatBox(content: string, title?: string): string {
const lines = content.split("\n")
const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4
const border = color.dim("\u2500".repeat(maxWidth))
const output: string[] = []
output.push("")
if (title) {
output.push(
color.dim("\u250C\u2500") +
color.bold(` ${title} `) +
color.dim("\u2500".repeat(maxWidth - title.length - 4)) +
color.dim("\u2510")
)
} else {
output.push(color.dim("\u250C") + border + color.dim("\u2510"))
}
for (const line of lines) {
const stripped = stripAnsi(line)
const padding = maxWidth - stripped.length
output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502"))
}
output.push(color.dim("\u2514") + border + color.dim("\u2518"))
output.push("")
return output.join("\n")
}
export function formatHelpSuggestions(results: CheckResult[]): string[] {
const suggestions: string[] = []
for (const result of results) {
if (result.status === "fail" && result.details) {
for (const detail of result.details) {
if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) {
suggestions.push(detail)
}
}
}
}
return suggestions
}

View File

@@ -1,11 +1,11 @@
import type { DoctorOptions } from "./types"
import { runDoctor } from "./runner"
export async function doctor(options: DoctorOptions = { mode: "default" }): Promise<number> {
export async function doctor(options: DoctorOptions = {}): Promise<number> {
const result = await runDoctor(options)
return result.exitCode
}
export * from "./types"
export { runDoctor } from "./runner"
export { formatDoctorOutput, formatJsonOutput } from "./formatter"
export { formatJsonOutput } from "./formatter"

View File

@@ -1,233 +1,153 @@
import { afterEach, describe, expect, it, mock } from "bun:test"
import type { CheckDefinition, CheckResult, DoctorResult, SystemInfo, ToolsSummary } from "./types"
function createSystemInfo(): SystemInfo {
return {
opencodeVersion: "1.0.200",
opencodePath: "/usr/local/bin/opencode",
pluginVersion: "3.4.0",
loadedVersion: "3.4.0",
bunVersion: "1.2.0",
configPath: "/tmp/opencode.json",
configValid: true,
isLocalDev: false,
}
}
function createTools(): ToolsSummary {
return {
lspInstalled: 1,
lspTotal: 4,
astGrepCli: true,
astGrepNapi: false,
commentChecker: true,
ghCli: { installed: true, authenticated: true, username: "yeongyu" },
mcpBuiltin: ["context7"],
mcpUser: ["custom-mcp"],
}
}
function createPassResult(name: string): CheckResult {
return { name, status: "pass", message: "ok", issues: [] }
}
function createDeferred(): {
promise: Promise<CheckResult>
resolve: (value: CheckResult) => void
} {
let resolvePromise: (value: CheckResult) => void = () => {}
const promise = new Promise<CheckResult>((resolve) => {
resolvePromise = resolve
})
return { promise, resolve: resolvePromise }
}
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import {
runCheck,
calculateSummary,
determineExitCode,
filterChecksByCategory,
groupChecksByCategory,
} from "./runner"
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
describe("runner", () => {
afterEach(() => {
mock.restore()
})
describe("runCheck", () => {
it("returns fail result with issue when check throws", async () => {
//#given
it("returns result from check function", async () => {
const check: CheckDefinition = {
id: "system",
name: "System",
check: async () => {
throw new Error("boom")
},
id: "test",
name: "Test Check",
category: "installation",
check: async () => ({ name: "Test Check", status: "pass", message: "OK" }),
}
const result = await runCheck(check)
expect(result.name).toBe("Test Check")
expect(result.status).toBe("pass")
})
it("measures duration", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
await new Promise((r) => setTimeout(r, 50))
return { name: "Test", status: "pass", message: "OK" }
},
}
const result = await runCheck(check)
expect(result.duration).toBeGreaterThanOrEqual(10)
})
it("returns fail on error", async () => {
const check: CheckDefinition = {
id: "test",
name: "Test Check",
category: "installation",
check: async () => {
throw new Error("Test error")
},
}
const { runCheck } = await import(`./runner?run-check-error=${Date.now()}`)
//#when
const result = await runCheck(check)
//#then
expect(result.status).toBe("fail")
expect(result.message).toBe("boom")
expect(result.issues[0]?.title).toBe("System")
expect(result.issues[0]?.severity).toBe("error")
expect(typeof result.duration).toBe("number")
expect(result.message).toContain("Test error")
})
})
describe("calculateSummary", () => {
it("counts statuses correctly", async () => {
//#given
const { calculateSummary } = await import(`./runner?summary=${Date.now()}`)
it("counts each status correctly", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "pass", message: "", issues: [] },
{ name: "3", status: "fail", message: "", issues: [] },
{ name: "4", status: "warn", message: "", issues: [] },
{ name: "5", status: "skip", message: "", issues: [] },
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
{ name: "3", status: "fail", message: "" },
{ name: "4", status: "warn", message: "" },
{ name: "5", status: "skip", message: "" },
]
//#when
const summary = calculateSummary(results, 19.9)
const summary = calculateSummary(results, 100)
//#then
expect(summary.total).toBe(5)
expect(summary.passed).toBe(2)
expect(summary.failed).toBe(1)
expect(summary.warnings).toBe(1)
expect(summary.skipped).toBe(1)
expect(summary.duration).toBe(20)
expect(summary.duration).toBe(100)
})
})
describe("determineExitCode", () => {
it("returns zero when no failures exist", async () => {
//#given
const { determineExitCode } = await import(`./runner?exit-ok=${Date.now()}`)
it("returns 0 when all pass", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "warn", message: "", issues: [] },
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "pass", message: "" },
]
//#when
const code = determineExitCode(results)
//#then
expect(code).toBe(0)
expect(determineExitCode(results)).toBe(0)
})
it("returns one when any failure exists", async () => {
//#given
const { determineExitCode } = await import(`./runner?exit-fail=${Date.now()}`)
it("returns 0 when only warnings", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "", issues: [] },
{ name: "2", status: "fail", message: "", issues: [] },
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "warn", message: "" },
]
//#when
const code = determineExitCode(results)
expect(determineExitCode(results)).toBe(0)
})
//#then
expect(code).toBe(1)
it("returns 1 when any failures", () => {
const results: CheckResult[] = [
{ name: "1", status: "pass", message: "" },
{ name: "2", status: "fail", message: "" },
]
expect(determineExitCode(results)).toBe(1)
})
})
describe("runDoctor", () => {
it("starts all checks in parallel and returns collected result", async () => {
//#given
const startedChecks: string[] = []
const deferredOne = createDeferred()
const deferredTwo = createDeferred()
const deferredThree = createDeferred()
const deferredFour = createDeferred()
describe("filterChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) },
]
const checks: CheckDefinition[] = [
{
id: "system",
name: "System",
check: async () => {
startedChecks.push("system")
return deferredOne.promise
},
},
{
id: "config",
name: "Configuration",
check: async () => {
startedChecks.push("config")
return deferredTwo.promise
},
},
{
id: "tools",
name: "Tools",
check: async () => {
startedChecks.push("tools")
return deferredThree.promise
},
},
{
id: "models",
name: "Models",
check: async () => {
startedChecks.push("models")
return deferredFour.promise
},
},
]
it("returns all checks when no category", () => {
const filtered = filterChecksByCategory(checks)
const expectedResult: DoctorResult = {
results: [
createPassResult("System"),
createPassResult("Configuration"),
createPassResult("Tools"),
createPassResult("Models"),
],
systemInfo: createSystemInfo(),
tools: createTools(),
summary: {
total: 4,
passed: 4,
failed: 0,
warnings: 0,
skipped: 0,
duration: 0,
},
exitCode: 0,
}
expect(filtered.length).toBe(3)
})
const formatDoctorOutputMock = mock((result: DoctorResult) => result.summary.total.toString())
const formatJsonOutputMock = mock((result: DoctorResult) => JSON.stringify(result))
it("filters to specific category", () => {
const filtered = filterChecksByCategory(checks, "installation")
mock.module("./checks", () => ({
getAllCheckDefinitions: () => checks,
gatherSystemInfo: async () => expectedResult.systemInfo,
gatherToolsSummary: async () => expectedResult.tools,
}))
mock.module("./formatter", () => ({
formatDoctorOutput: formatDoctorOutputMock,
formatJsonOutput: formatJsonOutputMock,
}))
expect(filtered.length).toBe(1)
expect(filtered[0].name).toBe("Install")
})
})
const logSpy = mock(() => {})
const originalLog = console.log
console.log = logSpy
describe("groupChecksByCategory", () => {
const checks: CheckDefinition[] = [
{ id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
{ id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
]
const { runDoctor } = await import(`./runner?parallel=${Date.now()}`)
const runPromise = runDoctor({ mode: "default" })
it("groups checks by category", () => {
const groups = groupChecksByCategory(checks)
//#when
await Promise.resolve()
const startedBeforeResolve = [...startedChecks]
deferredOne.resolve(createPassResult("System"))
deferredTwo.resolve(createPassResult("Configuration"))
deferredThree.resolve(createPassResult("Tools"))
deferredFour.resolve(createPassResult("Models"))
const result = await runPromise
expect(groups.get("installation")?.length).toBe(2)
expect(groups.get("configuration")?.length).toBe(1)
})
//#then
console.log = originalLog
expect(startedBeforeResolve.sort()).toEqual(["config", "models", "system", "tools"])
expect(result.results.length).toBe(4)
expect(result.exitCode).toBe(0)
expect(formatDoctorOutputMock).toHaveBeenCalledTimes(1)
expect(formatJsonOutputMock).toHaveBeenCalledTimes(0)
it("maintains order within categories", () => {
const groups = groupChecksByCategory(checks)
const installChecks = groups.get("installation")!
expect(installChecks[0].name).toBe("Install1")
expect(installChecks[1].name).toBe("Install2")
})
})
})

View File

@@ -1,7 +1,21 @@
import type { DoctorOptions, DoctorResult, CheckDefinition, CheckResult, DoctorSummary } from "./types"
import { getAllCheckDefinitions, gatherSystemInfo, gatherToolsSummary } from "./checks"
import { EXIT_CODES } from "./constants"
import { formatDoctorOutput, formatJsonOutput } from "./formatter"
import type {
DoctorOptions,
DoctorResult,
CheckDefinition,
CheckResult,
DoctorSummary,
CheckCategory,
} from "./types"
import { getAllCheckDefinitions } from "./checks"
import { EXIT_CODES, CATEGORY_NAMES } from "./constants"
import {
formatHeader,
formatCategoryHeader,
formatCheckResult,
formatSummary,
formatFooter,
formatJsonOutput,
} from "./formatter"
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
const start = performance.now()
@@ -14,7 +28,6 @@ export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
name: check.name,
status: "fail",
message: err instanceof Error ? err.message : "Unknown error",
issues: [{ title: check.name, description: String(err), severity: "error" }],
duration: Math.round(performance.now() - start),
}
}
@@ -32,18 +45,70 @@ export function calculateSummary(results: CheckResult[], duration: number): Doct
}
export function determineExitCode(results: CheckResult[]): number {
return results.some((r) => r.status === "fail") ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
const hasFailures = results.some((r) => r.status === "fail")
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
}
export function filterChecksByCategory(
checks: CheckDefinition[],
category?: CheckCategory
): CheckDefinition[] {
if (!category) return checks
return checks.filter((c) => c.category === category)
}
export function groupChecksByCategory(
checks: CheckDefinition[]
): Map<CheckCategory, CheckDefinition[]> {
const groups = new Map<CheckCategory, CheckDefinition[]>()
for (const check of checks) {
const existing = groups.get(check.category) ?? []
existing.push(check)
groups.set(check.category, existing)
}
return groups
}
const CATEGORY_ORDER: CheckCategory[] = [
"installation",
"configuration",
"authentication",
"dependencies",
"tools",
"updates",
]
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const start = performance.now()
const allChecks = getAllCheckDefinitions()
const [results, systemInfo, tools] = await Promise.all([
Promise.all(allChecks.map(runCheck)),
gatherSystemInfo(),
gatherToolsSummary(),
])
const filteredChecks = filterChecksByCategory(allChecks, options.category)
const groupedChecks = groupChecksByCategory(filteredChecks)
const results: CheckResult[] = []
if (!options.json) {
console.log(formatHeader())
}
for (const category of CATEGORY_ORDER) {
const checks = groupedChecks.get(category)
if (!checks || checks.length === 0) continue
if (!options.json) {
console.log(formatCategoryHeader(category))
}
for (const check of checks) {
const result = await runCheck(check)
results.push(result)
if (!options.json) {
console.log(formatCheckResult(result, options.verbose ?? false))
}
}
}
const duration = performance.now() - start
const summary = calculateSummary(results, duration)
@@ -51,8 +116,6 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
const doctorResult: DoctorResult = {
results,
systemInfo,
tools,
summary,
exitCode,
}
@@ -60,7 +123,9 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
if (options.json) {
console.log(formatJsonOutput(doctorResult))
} else {
console.log(formatDoctorOutput(doctorResult, options.mode))
console.log("")
console.log(formatSummary(summary))
console.log(formatFooter(summary))
}
return doctorResult

View File

@@ -1,20 +1,3 @@
// ===== New 3-tier doctor types =====
export type DoctorMode = "default" | "status" | "verbose"
export interface DoctorOptions {
mode: DoctorMode
json?: boolean
}
export interface DoctorIssue {
title: string
description: string
fix?: string
affects?: string[]
severity: "error" | "warning"
}
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
export interface CheckResult {
@@ -22,39 +5,31 @@ export interface CheckResult {
status: CheckStatus
message: string
details?: string[]
issues: DoctorIssue[]
duration?: number
}
export type CheckFunction = () => Promise<CheckResult>
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface CheckDefinition {
id: string
name: string
category: CheckCategory
check: CheckFunction
critical?: boolean
}
export interface SystemInfo {
opencodeVersion: string | null
opencodePath: string | null
pluginVersion: string | null
loadedVersion: string | null
bunVersion: string | null
configPath: string | null
configValid: boolean
isLocalDev: boolean
}
export interface ToolsSummary {
lspInstalled: number
lspTotal: number
astGrepCli: boolean
astGrepNapi: boolean
commentChecker: boolean
ghCli: { installed: boolean; authenticated: boolean; username: string | null }
mcpBuiltin: string[]
mcpUser: string[]
export interface DoctorOptions {
verbose?: boolean
json?: boolean
category?: CheckCategory
}
export interface DoctorSummary {
@@ -68,22 +43,10 @@ export interface DoctorSummary {
export interface DoctorResult {
results: CheckResult[]
systemInfo: SystemInfo
tools: ToolsSummary
summary: DoctorSummary
exitCode: number
}
// ===== Legacy types (used by existing checks until migration) =====
export type CheckCategory =
| "installation"
| "configuration"
| "authentication"
| "dependencies"
| "tools"
| "updates"
export interface OpenCodeInfo {
installed: boolean
version: string | null

View File

@@ -55,7 +55,7 @@ describe("pollForCompletion", () => {
})
it("does not check completion during stabilization period after first meaningful work", async () => {
//#given - session idle, meaningful work done, but stabilization period not elapsed
//#given - 0 todos, 0 children, session idle, meaningful work done, short stabilization
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
@@ -64,15 +64,15 @@ describe("pollForCompletion", () => {
eventState.hasReceivedMeaningfulWork = true
const abortController = new AbortController()
//#when - abort after 50ms (within the 60ms stabilization period)
//#when - abort before stabilization period ends
setTimeout(() => abortController.abort(), 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
minStabilizationMs: 60,
minStabilizationMs: 200,
})
//#then - should be aborted, not completed (stabilization blocked completion check)
//#then - should be aborted (130) because stabilization hadn't elapsed yet
expect(result).toBe(130)
const todoCallCount = (ctx.client.session.todo as ReturnType<typeof mock>).mock.calls.length
expect(todoCallCount).toBe(0)

View File

@@ -71,7 +71,10 @@ export async function pollForCompletion(
firstWorkTimestamp = Date.now()
}
// Don't check completion during stabilization period
// Don't check completion until stabilization period has elapsed.
// Agents need time to set up todos and spawn child sessions after
// their first output. Without this, empty todos + no children
// triggers a false "all complete" within ~1.5s of starting.
if (Date.now() - firstWorkTimestamp < minStabilizationMs) {
consecutiveCompleteChecks = 0
continue

View File

@@ -1,8 +1,6 @@
/// <reference types="bun-types" />
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
import { resolveRunAgent } from "./runner"
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
...overrides,
@@ -70,59 +68,3 @@ describe("resolveRunAgent", () => {
expect(agent).toBe("hephaestus")
})
})
describe("waitForEventProcessorShutdown", () => {
let consoleLogSpy: ReturnType<typeof spyOn<typeof console, "log">> | null = null
afterEach(() => {
if (consoleLogSpy) {
consoleLogSpy.mockRestore()
consoleLogSpy = null
}
})
it("returns quickly when event processor completes", async () => {
//#given
const eventProcessor = new Promise<void>((resolve) => {
setTimeout(() => {
resolve()
}, 25)
})
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
const start = performance.now()
//#when
await waitForEventProcessorShutdown(eventProcessor, 200)
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeLessThan(200)
expect(console.log).not.toHaveBeenCalledWith(
"[run] Event stream did not close within 200ms after abort; continuing shutdown.",
)
})
it("times out and continues when event processor does not complete", async () => {
//#given
const eventProcessor = new Promise<void>(() => {})
const spy = spyOn(console, "log").mockImplementation(() => {})
consoleLogSpy = spy
const timeoutMs = 50
const start = performance.now()
try {
//#when
await waitForEventProcessorShutdown(eventProcessor, timeoutMs)
//#then
const elapsed = performance.now() - start
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs)
const callArgs = spy.mock.calls.flat().join("")
expect(callArgs).toContain(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
)
} finally {
spy.mockRestore()
}
})
})

View File

@@ -12,25 +12,6 @@ import { pollForCompletion } from "./poll-for-completion"
export { resolveRunAgent }
const DEFAULT_TIMEOUT_MS = 600_000
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
export async function waitForEventProcessorShutdown(
eventProcessor: Promise<void>,
timeoutMs = EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS,
): Promise<void> {
const completed = await Promise.race([
eventProcessor.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), timeoutMs)),
])
if (!completed) {
console.log(
pc.dim(
`[run] Event stream did not close within ${timeoutMs}ms after abort; continuing shutdown.`,
),
)
}
}
export async function run(options: RunOptions): Promise<number> {
process.env.OPENCODE_CLI_RUN_MODE = "true"
@@ -84,7 +65,7 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.dim(`Session: ${sessionID}`))
const ctx: RunContext = { client, sessionID, directory, abortController }
const events = await client.event.subscribe({ query: { directory } })
const events = await client.event.subscribe()
const eventState = createEventState()
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {},
@@ -100,14 +81,14 @@ export async function run(options: RunOptions): Promise<number> {
query: { directory },
})
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
console.log(pc.dim("Waiting for completion...\n"))
const exitCode = await pollForCompletion(ctx, eventState, abortController)
// Abort the event stream to stop the processor
abortController.abort()
// Abort the event stream to stop the processor
abortController.abort()
await waitForEventProcessorShutdown(eventProcessor)
cleanup()
await eventProcessor
cleanup()
const durationMs = Date.now() - startTime
@@ -146,3 +127,4 @@ export async function run(options: RunOptions): Promise<number> {
return 1
}
}

View File

@@ -733,20 +733,3 @@ describe("GitMasterConfigSchema", () => {
expect(result.success).toBe(false)
})
})
describe("skills schema", () => {
test("accepts skills.sources configuration", () => {
//#given
const config = {
skills: {
sources: [{ path: "skill/", recursive: true }],
},
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
})
})

View File

@@ -12,7 +12,6 @@ export const AgentOverrideConfigSchema = z.object({
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
prompt: z.string().optional(),
/** Text to append to agent prompt. Supports file:// URIs (file:///abs, file://./rel, file://~/home) */
prompt_append: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
disable: z.boolean().optional(),

View File

@@ -6,8 +6,6 @@ export const BackgroundTaskConfigSchema = z.object({
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 600000 = 10 minutes, minimum: 60000 = 1 minute) */
messageStalenessTimeoutMs: z.number().min(60000).optional(),
})
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

View File

@@ -13,6 +13,7 @@ export const HookNameSchema = z.enum([
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"subagent-question-blocker",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",

View File

@@ -28,11 +28,17 @@ export const SkillEntrySchema = z.union([z.boolean(), SkillDefinitionSchema])
export const SkillsConfigSchema = z.union([
z.array(z.string()),
z.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
}).catchall(SkillEntrySchema),
z
.record(z.string(), SkillEntrySchema)
.and(
z
.object({
sources: z.array(SkillSourceSchema).optional(),
enable: z.array(z.string()).optional(),
disable: z.array(z.string()).optional(),
})
.partial()
),
])
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>

View File

@@ -1,7 +1,7 @@
import { log } from "../../shared"
import { MIN_IDLE_TIME_MS } from "./constants"
import { subagentSessions } from "../claude-code-session-state"
import type { BackgroundTask } from "./types"
import { cleanupTaskAfterSessionEnds } from "./session-task-cleanup"
import { handleSessionIdleBackgroundEvent } from "./session-idle-event-handler"
type Event = { type: string; properties?: Record<string, unknown> }
@@ -18,7 +18,6 @@ export function handleBackgroundEvent(args: {
event: Event
findBySession: (sessionID: string) => BackgroundTask | undefined
getAllDescendantTasks: (sessionID: string) => BackgroundTask[]
releaseConcurrencyKey?: (key: string) => void
cancelTask: (
taskId: string,
options: { source: string; reason: string; skipNotification: true }
@@ -37,7 +36,6 @@ export function handleBackgroundEvent(args: {
event,
findBySession,
getAllDescendantTasks,
releaseConcurrencyKey,
cancelTask,
tryCompleteTask,
validateSessionHasOutput,
@@ -52,7 +50,7 @@ export function handleBackgroundEvent(args: {
const props = event.properties
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (event.type === "message.part.updated") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return
@@ -69,31 +67,17 @@ export function handleBackgroundEvent(args: {
const type = getString(props, "type")
const tool = getString(props, "tool")
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.lastUpdate = new Date()
if (type === "tool" || tool) {
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls += 1
task.progress.lastTool = tool
task.progress.lastUpdate = new Date()
}
}
if (event.type === "session.idle") {
if (!props || !isRecord(props)) return
handleSessionIdleBackgroundEvent({
properties: props,
findBySession,
idleDeferralTimers,
validateSessionHasOutput,
checkSessionTodos,
tryCompleteTask,
emitIdleEvent,
})
}
if (event.type === "session.error") {
if (!props || !isRecord(props)) return
const sessionID = getString(props, "sessionID")
if (!sessionID) return
@@ -101,26 +85,64 @@ export function handleBackgroundEvent(args: {
const task = findBySession(sessionID)
if (!task || task.status !== "running") return
const errorRaw = props["error"]
const dataRaw = isRecord(errorRaw) ? errorRaw["data"] : undefined
const message =
(isRecord(dataRaw) ? getString(dataRaw, "message") : undefined) ??
(isRecord(errorRaw) ? getString(errorRaw, "message") : undefined) ??
"Session error"
const startedAt = task.startedAt
if (!startedAt) return
task.status = "error"
task.error = message
task.completedAt = new Date()
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs < MIN_IDLE_TIME_MS) {
const remainingMs = MIN_IDLE_TIME_MS - elapsedMs
if (!idleDeferralTimers.has(task.id)) {
log("[background-agent] Deferring early session.idle:", {
elapsedMs,
remainingMs,
taskId: task.id,
})
const timer = setTimeout(() => {
idleDeferralTimers.delete(task.id)
emitIdleEvent(sessionID)
}, remainingMs)
idleDeferralTimers.set(task.id, timer)
} else {
log("[background-agent] session.idle already deferred:", { elapsedMs, taskId: task.id })
}
return
}
cleanupTaskAfterSessionEnds({
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
})
validateSessionHasOutput(sessionID)
.then(async (hasValidOutput) => {
if (task.status !== "running") {
log("[background-agent] Task status changed during validation, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}
const hasIncompleteTodos = await checkSessionTodos(sessionID)
if (task.status !== "running") {
log("[background-agent] Task status changed during todo check, skipping:", {
taskId: task.id,
status: task.status,
})
return
}
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}
await tryCompleteTask(task, "session.idle event")
})
.catch((err) => {
log("[background-agent] Error in session.idle handler:", err)
})
}
if (event.type === "session.deleted") {
@@ -154,15 +176,24 @@ export function handleBackgroundEvent(args: {
})
}
cleanupTaskAfterSessionEnds({
task,
tasks,
idleDeferralTimers,
completionTimers,
cleanupPendingByParent,
clearNotificationsForTask,
releaseConcurrencyKey,
})
const completionTimer = completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
completionTimers.delete(task.id)
}
const idleTimer = idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
idleDeferralTimers.delete(task.id)
}
cleanupPendingByParent(task)
tasks.delete(task.id)
clearNotificationsForTask(task.id)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
}
}

View File

@@ -4,7 +4,6 @@ import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000
export const MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 600_000
export const MIN_RUNTIME_BEFORE_STALE_MS = 30_000
export const MIN_IDLE_TIME_MS = 5000
export const POLLING_INTERVAL_MS = 3000

View File

@@ -1,5 +1,4 @@
export * from "./types"
export { BackgroundManager, type SubagentSessionCreatedEvent, type OnSubagentSessionCreated } from "./manager"
export { TaskHistory, type TaskHistoryEntry } from "./task-history"
export { ConcurrencyManager } from "./concurrency"
export { TaskStateManager } from "./state"

View File

@@ -6,7 +6,6 @@ import type { BackgroundTask, ResumeInput } from "./types"
import { MIN_IDLE_TIME_MS } from "./constants"
import { BackgroundManager } from "./manager"
import { ConcurrencyManager } from "./concurrency"
import { initTaskToastManager, _resetTaskToastManagerForTesting } from "../task-toast-manager/manager"
const TASK_TTL_MS = 30 * 60 * 1000
@@ -191,26 +190,6 @@ function getPendingByParent(manager: BackgroundManager): Map<string, Set<string>
return (manager as unknown as { pendingByParent: Map<string, Set<string>> }).pendingByParent
}
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function getQueuesByKey(
manager: BackgroundManager
): Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>> {
return (manager as unknown as {
queuesByKey: Map<string, Array<{ task: BackgroundTask; input: import("./types").LaunchInput }>>
}).queuesByKey
}
async function processKeyForTest(manager: BackgroundManager, key: string): Promise<void> {
return (manager as unknown as { processKey: (key: string) => Promise<void> }).processKey(key)
}
function pruneStaleTasksAndNotificationsForTest(manager: BackgroundManager): void {
;(manager as unknown as { pruneStaleTasksAndNotifications: () => void }).pruneStaleTasksAndNotifications()
}
async function tryCompleteTaskForTest(manager: BackgroundManager, task: BackgroundTask): Promise<boolean> {
return (manager as unknown as { tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean> })
.tryCompleteTask(task, "test")
@@ -220,23 +199,6 @@ function stubNotifyParentSession(manager: BackgroundManager): void {
;(manager as unknown as { notifyParentSession: () => Promise<void> }).notifyParentSession = async () => {}
}
function createToastRemoveTaskTracker(): { removeTaskCalls: string[]; resetToastManager: () => void } {
_resetTaskToastManagerForTesting()
const toastManager = initTaskToastManager({
tui: { showToast: async () => {} },
} as unknown as PluginInput["client"])
const removeTaskCalls: string[] = []
const originalRemoveTask = toastManager.removeTask.bind(toastManager)
toastManager.removeTask = (taskId: string): void => {
removeTaskCalls.push(taskId)
originalRemoveTask(taskId)
}
return {
removeTaskCalls,
resetToastManager: _resetTaskToastManagerForTesting,
}
}
function getCleanupSignals(): Array<NodeJS.Signals | "beforeExit" | "exit"> {
const signals: Array<NodeJS.Signals | "beforeExit" | "exit"> = ["SIGINT", "SIGTERM", "beforeExit", "exit"]
if (process.platform === "win32") {
@@ -916,7 +878,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
})
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should fall back and still notify when parent session messages are aborted", async () => {
test("should skip notification when parent session is aborted", async () => {
//#given
let promptCalled = false
const promptMock = async () => {
@@ -955,7 +917,7 @@ describe("BackgroundManager.notifyParentSession - aborted parent", () => {
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
expect(promptCalled).toBe(false)
manager.shutdown()
})
@@ -1792,32 +1754,6 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
const pendingSet = pendingByParent.get(task.parentSessionID)
expect(pendingSet?.has(task.id) ?? false).toBe(false)
})
test("should remove task from toast manager when notification is skipped", async () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const task = createMockTask({
id: "task-cancel-skip-notification",
sessionID: "session-cancel-skip-notification",
parentSessionID: "parent-cancel-skip-notification",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
const cancelled = await manager.cancelTask(task.id, {
source: "test",
skipNotification: true,
})
//#then
expect(cancelled).toBe(true)
expect(removeTaskCalls).toContain(task.id)
manager.shutdown()
resetToastManager()
})
})
describe("multiple keys process in parallel", () => {
@@ -2337,221 +2273,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
getTaskMap(manager).set(task.id, task)
await manager["checkAndInterruptStaleTasks"]()
await manager["checkAndInterruptStaleTasks"]()
expect(task.status).toBe("cancelled")
})
test("should NOT interrupt task when session is running, even with stale lastUpdate", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-running-session",
sessionID: "session-running",
parentSessionID: "parent-rs",
parentMessageID: "msg-rs",
description: "Task with running session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is actively running
await manager["checkAndInterruptStaleTasks"]({ "session-running": { type: "running" } })
//#then — task survives because session is running
expect(task.status).toBe("running")
})
test("should interrupt task when session is idle and lastUpdate exceeds stale timeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-idle-session",
sessionID: "session-idle",
parentSessionID: "parent-is",
parentMessageID: "msg-is",
description: "Task with idle session",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 2,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is idle
await manager["checkAndInterruptStaleTasks"]({ "session-idle": { type: "idle" } })
//#then — killed because session is idle with stale lastUpdate
expect(task.status).toBe("cancelled")
expect(task.error).toContain("Stale timeout")
})
test("should NOT interrupt running session even with very old lastUpdate (no safety net)", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
const task: BackgroundTask = {
id: "task-long-running",
sessionID: "session-long",
parentSessionID: "parent-lr",
parentMessageID: "msg-lr",
description: "Long running task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 900_000),
progress: {
toolCalls: 5,
lastUpdate: new Date(Date.now() - 900_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when — session is running, lastUpdate 15min old
await manager["checkAndInterruptStaleTasks"]({ "session-long": { type: "running" } })
//#then — running sessions are NEVER stale-killed
expect(task.status).toBe("running")
})
test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-running-no-progress",
sessionID: "session-rnp",
parentSessionID: "parent-rnp",
parentMessageID: "msg-rnp",
description: "Running no progress",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — session is running despite no progress
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })
//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
})
test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-no-update",
sessionID: "session-no-update",
parentSessionID: "parent-nu",
parentMessageID: "msg-nu",
description: "No update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — no progress update for 15 minutes
await manager["checkAndInterruptStaleTasks"]({})
//#then — killed after messageStalenessTimeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("no activity")
})
test("should NOT interrupt task with no lastUpdate within messageStalenessTimeout", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { messageStalenessTimeoutMs: 600_000 })
const task: BackgroundTask = {
id: "task-fresh-no-update",
sessionID: "session-fresh",
parentSessionID: "parent-fn",
parentMessageID: "msg-fn",
description: "Fresh no-update task",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 5 * 60 * 1000),
progress: undefined,
}
getTaskMap(manager).set(task.id, task)
//#when — only 5 min since start, within 10min timeout
await manager["checkAndInterruptStaleTasks"]({})
//#then — task survives
expect(task.status).toBe("running")
})
})
describe("BackgroundManager.shutdown session abort", () => {
@@ -2778,290 +2503,13 @@ describe("BackgroundManager.handleEvent - session.deleted cascade", () => {
manager.shutdown()
})
test("should remove tasks from toast manager when session is deleted", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const parentSessionID = "session-parent-toast"
const childTask = createMockTask({
id: "task-child-toast",
sessionID: "session-child-toast",
parentSessionID,
status: "running",
})
const grandchildTask = createMockTask({
id: "task-grandchild-toast",
sessionID: "session-grandchild-toast",
parentSessionID: "session-child-toast",
status: "pending",
startedAt: undefined,
queuedAt: new Date(),
})
const taskMap = getTaskMap(manager)
taskMap.set(childTask.id, childTask)
taskMap.set(grandchildTask.id, grandchildTask)
//#when
manager.handleEvent({
type: "session.deleted",
properties: { info: { id: parentSessionID } },
})
//#then
expect(removeTaskCalls).toContain(childTask.id)
expect(removeTaskCalls).toContain(grandchildTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.handleEvent - session.error", () => {
test("sets task to error, releases concurrency, and cleans up", async () => {
//#given
const manager = createBackgroundManager()
const concurrencyManager = getConcurrencyManager(manager)
const concurrencyKey = "test-provider/test-model"
await concurrencyManager.acquire(concurrencyKey)
const sessionID = "ses_error_1"
const task = createMockTask({
id: "task-session-error",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task that errors",
agent: "explore",
status: "running",
concurrencyKey,
})
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: {
name: "UnknownError",
data: { message: "Model not found: kimi-for-coding/k2p5." },
},
},
})
//#then
expect(task.status).toBe("error")
expect(task.error).toBe("Model not found: kimi-for-coding/k2p5.")
expect(task.completedAt).toBeInstanceOf(Date)
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getTaskMap(manager).has(task.id)).toBe(false)
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
manager.shutdown()
})
test("removes errored task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const sessionID = "ses_error_toast"
const task = createMockTask({
id: "task-session-error-toast",
sessionID,
parentSessionID: "parent-session",
status: "running",
})
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "boom" },
},
})
//#then
expect(removeTaskCalls).toContain(task.id)
manager.shutdown()
resetToastManager()
})
test("ignores session.error for non-running tasks", () => {
//#given
const manager = createBackgroundManager()
const sessionID = "ses_error_ignored"
const task = createMockTask({
id: "task-non-running",
sessionID,
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task already done",
agent: "explore",
status: "completed",
})
task.completedAt = new Date()
task.error = "previous"
getTaskMap(manager).set(task.id, task)
//#when
manager.handleEvent({
type: "session.error",
properties: {
sessionID,
error: { name: "UnknownError", message: "should not matter" },
},
})
//#then
expect(task.status).toBe("completed")
expect(task.error).toBe("previous")
expect(getTaskMap(manager).has(task.id)).toBe(true)
manager.shutdown()
})
test("ignores session.error for unknown session", () => {
//#given
const manager = createBackgroundManager()
//#when
const handler = () =>
manager.handleEvent({
type: "session.error",
properties: {
sessionID: "ses_unknown",
error: { name: "UnknownError", message: "Model not found" },
},
})
//#then
expect(handler).not.toThrow()
manager.shutdown()
})
})
describe("BackgroundManager queue processing - error tasks are skipped", () => {
test("does not start tasks with status=error", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
{ defaultConcurrency: 1 }
)
const key = "test-key"
const task: BackgroundTask = {
id: "task-error-queued",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "queued error task",
prompt: "test",
agent: "test-agent",
status: "error",
queuedAt: new Date(),
}
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
let startCalled = false
;(manager as unknown as { startTask: (item: unknown) => Promise<void> }).startTask = async () => {
startCalled = true
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
await processKeyForTest(manager, key)
//#then
expect(startCalled).toBe(false)
expect(getQueuesByKey(manager).get(key)?.length ?? 0).toBe(0)
manager.shutdown()
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications - removes pruned tasks from queuesByKey", () => {
test("removes stale pending task from queue", () => {
//#given
const manager = createBackgroundManager()
const queuedAt = new Date(Date.now() - 31 * 60 * 1000)
const task: BackgroundTask = {
id: "task-stale-pending",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "stale pending",
prompt: "test",
agent: "test-agent",
status: "pending",
queuedAt,
}
const key = task.agent
const input: import("./types").LaunchInput = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(key, [{ task, input }])
//#when
pruneStaleTasksAndNotificationsForTest(manager)
//#then
expect(getQueuesByKey(manager).get(key)).toBeUndefined()
manager.shutdown()
})
test("removes stale task from toast manager", () => {
//#given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const manager = createBackgroundManager()
const staleTask = createMockTask({
id: "task-stale-toast",
sessionID: "session-stale-toast",
parentSessionID: "parent-session",
status: "running",
startedAt: new Date(Date.now() - 31 * 60 * 1000),
})
getTaskMap(manager).set(staleTask.id, staleTask)
//#when
pruneStaleTasksAndNotificationsForTest(manager)
//#then
expect(removeTaskCalls).toContain(staleTask.id)
manager.shutdown()
resetToastManager()
})
})
describe("BackgroundManager.completionTimers - Memory Leak Fix", () => {
function getCompletionTimers(manager: BackgroundManager): Map<string, ReturnType<typeof setTimeout>> {
return (manager as unknown as { completionTimers: Map<string, ReturnType<typeof setTimeout>> }).completionTimers
}
function setCompletionTimer(manager: BackgroundManager, taskId: string): void {
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
@@ -3389,291 +2837,3 @@ describe("BackgroundManager.handleEvent - early session.idle deferral", () => {
}
})
})
describe("BackgroundManager.handleEvent - non-tool event lastUpdate", () => {
test("should update lastUpdate on text-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-text-1",
sessionID: "session-text-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Thinking task",
prompt: "Think deeply",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 2,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-text-1", type: "text" },
})
//#then - lastUpdate should be refreshed, toolCalls should NOT change
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(2)
})
test("should update lastUpdate on thinking-type message.part.updated event", () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const oldUpdate = new Date(Date.now() - 300_000)
const task: BackgroundTask = {
id: "task-thinking-1",
sessionID: "session-thinking-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task",
prompt: "Reason about architecture",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: oldUpdate,
},
}
getTaskMap(manager).set(task.id, task)
//#when - a thinking-type message.part.updated event arrives
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-thinking-1", type: "thinking" },
})
//#then - lastUpdate should be refreshed, toolCalls should remain 0
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(oldUpdate.getTime())
expect(task.progress!.toolCalls).toBe(0)
})
test("should initialize progress on first non-tool event", () => {
//#given - a running task with NO progress field
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-init-1",
sessionID: "session-init-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "New task",
prompt: "Start thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 60_000),
}
getTaskMap(manager).set(task.id, task)
//#when - a text-type event arrives before any tool call
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-init-1", type: "text" },
})
//#then - progress should be initialized with toolCalls: 0 and fresh lastUpdate
expect(task.progress).toBeDefined()
expect(task.progress!.toolCalls).toBe(0)
expect(task.progress!.lastUpdate.getTime()).toBeGreaterThan(Date.now() - 5000)
})
test("should NOT mark thinking model as stale when text events refresh lastUpdate", async () => {
//#given - a running task where text events keep lastUpdate fresh
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-alive-1",
sessionID: "session-alive-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Long thinking task",
prompt: "Deep reasoning",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a text event arrives, then stale check runs
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: "session-alive-1", type: "text" },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (text event refreshed lastUpdate)
expect(task.status).toBe("running")
})
test("should refresh lastUpdate on message.part.delta events (OpenCode >=1.2.0)", async () => {
//#given - a running task with stale lastUpdate
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
stubNotifyParentSession(manager)
const task: BackgroundTask = {
id: "task-delta-1",
sessionID: "session-delta-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reasoning task with delta events",
prompt: "Extended thinking",
agent: "oracle",
status: "running",
startedAt: new Date(Date.now() - 600_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 300_000),
},
}
getTaskMap(manager).set(task.id, task)
//#when - a message.part.delta event arrives (reasoning-delta or text-delta in OpenCode >=1.2.0)
manager.handleEvent({
type: "message.part.delta",
properties: { sessionID: "session-delta-1", field: "text", delta: "thinking..." },
})
await manager["checkAndInterruptStaleTasks"]()
//#then - task should still be running (delta event refreshed lastUpdate)
expect(task.status).toBe("running")
})
})
describe("BackgroundManager regression fixes - resume and aborted notification", () => {
test("should keep resumed task in memory after previous completion timer deadline", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-resume-timer-regression",
sessionID: "session-resume-timer-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "resume timer regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
concurrencyGroup: "explore",
}
getTaskMap(manager).set(task.id, task)
const completionTimers = getCompletionTimers(manager)
const timer = setTimeout(() => {
completionTimers.delete(task.id)
getTaskMap(manager).delete(task.id)
}, 25)
completionTimers.set(task.id, timer)
//#when
await manager.resume({
sessionId: "session-resume-timer-regression",
prompt: "resume task",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
await new Promise((resolve) => setTimeout(resolve, 60))
//#then
expect(getTaskMap(manager).has(task.id)).toBe(true)
expect(completionTimers.has(task.id)).toBe(false)
manager.shutdown()
})
test("should start cleanup timer even when promptAsync aborts", async () => {
//#given
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => {
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-cleanup-regression",
sessionID: "session-aborted-cleanup-regression",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "aborted prompt cleanup regression",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getTaskMap(manager).set(task.id, task)
getPendingByParent(manager).set(task.parentSessionID, new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> }).notifyParentSession(task)
//#then
expect(getCompletionTimers(manager).has(task.id)).toBe(true)
manager.shutdown()
})
})

View File

@@ -5,14 +5,11 @@ import type {
LaunchInput,
ResumeInput,
} from "./types"
import { TaskHistory } from "./task-history"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { setSessionTools } from "../../shared/session-tools-store"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
MIN_IDLE_TIME_MS,
MIN_RUNTIME_BEFORE_STALE_MS,
@@ -93,7 +90,6 @@ export class BackgroundManager {
private completionTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private idleDeferralTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
private notificationQueueByParent: Map<string, Promise<void>> = new Map()
readonly taskHistory = new TaskHistory()
constructor(
ctx: PluginInput,
@@ -143,13 +139,11 @@ export class BackgroundManager {
parentMessageID: input.parentMessageID,
parentModel: input.parentModel,
parentAgent: input.parentAgent,
parentTools: input.parentTools,
model: input.model,
category: input.category,
}
this.tasks.set(task.id, task)
this.taskHistory.record(input.parentSessionID, { id: task.id, agent: input.agent, description: input.description, status: "pending", category: input.category })
// Track for batched notifications immediately (pending state)
if (input.parentSessionID) {
@@ -198,7 +192,7 @@ export class BackgroundManager {
await this.concurrencyManager.acquire(key)
if (item.task.status === "cancelled" || item.task.status === "error") {
if (item.task.status === "cancelled") {
this.concurrencyManager.release(key)
queue.shift()
continue
@@ -297,7 +291,6 @@ export class BackgroundManager {
task.concurrencyKey = concurrencyKey
task.concurrencyGroup = concurrencyKey
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID, agent: input.agent, description: input.description, status: "running", category: input.category, startedAt: task.startedAt })
this.startPolling()
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
@@ -331,16 +324,12 @@ export class BackgroundManager {
...(launchModel ? { model: launchModel } : {}),
...(launchVariant ? { variant: launchVariant } : {}),
system: input.skillContent,
tools: (() => {
const tools = {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(sessionID, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(input.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -497,7 +486,6 @@ export class BackgroundManager {
this.tasks.set(task.id, task)
subagentSessions.add(input.sessionID)
this.startPolling()
this.taskHistory.record(input.parentSessionID, { id: task.id, sessionID: input.sessionID, agent: input.agent || "task", description: input.description, status: "running", startedAt: task.startedAt })
if (input.parentSessionID) {
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
@@ -528,12 +516,6 @@ export class BackgroundManager {
return existingTask
}
const completionTimer = this.completionTimers.get(existingTask.id)
if (completionTimer) {
clearTimeout(completionTimer)
this.completionTimers.delete(existingTask.id)
}
// Re-acquire concurrency using the persisted concurrency group
const concurrencyKey = existingTask.concurrencyGroup ?? existingTask.agent
await this.concurrencyManager.acquire(concurrencyKey)
@@ -548,9 +530,6 @@ export class BackgroundManager {
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
if (input.parentTools) {
existingTask.parentTools = input.parentTools
}
// Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask.startedAt = new Date()
@@ -604,16 +583,12 @@ export class BackgroundManager {
agent: existingTask.agent,
...(resumeModel ? { model: resumeModel } : {}),
...(resumeVariant ? { variant: resumeVariant } : {}),
tools: (() => {
const tools = {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
}
setSessionTools(existingTask.sessionID!, tools)
return tools
})(),
tools: {
...getAgentToolRestrictions(existingTask.agent),
task: false,
call_omo_agent: true,
question: false,
},
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
@@ -666,7 +641,7 @@ export class BackgroundManager {
handleEvent(event: Event): void {
const props = event.properties
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
if (event.type === "message.part.updated") {
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
@@ -682,17 +657,16 @@ export class BackgroundManager {
this.idleDeferralTimers.delete(task.id)
}
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
}
task.progress.lastUpdate = new Date()
if (partInfo?.type === "tool" || partInfo?.tool) {
if (!task.progress) {
task.progress = {
toolCalls: 0,
lastUpdate: new Date(),
}
}
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
task.progress.lastUpdate = new Date()
}
}
@@ -755,49 +729,6 @@ export class BackgroundManager {
})
}
if (event.type === "session.error") {
const sessionID = typeof props?.sessionID === "string" ? props.sessionID : undefined
if (!sessionID) return
const task = this.findBySession(sessionID)
if (!task || task.status !== "running") return
const errorMessage = props ? this.getSessionErrorMessage(props) : undefined
task.status = "error"
task.error = errorMessage ?? "Session error"
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
const completionTimer = this.completionTimers.get(task.id)
if (completionTimer) {
clearTimeout(completionTimer)
this.completionTimers.delete(task.id)
}
const idleTimer = this.idleDeferralTimers.get(task.id)
if (idleTimer) {
clearTimeout(idleTimer)
this.idleDeferralTimers.delete(task.id)
}
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
}
if (event.type === "session.deleted") {
const info = props?.info
if (!info || typeof info.id !== "string") return
@@ -840,10 +771,6 @@ export class BackgroundManager {
this.cleanupPendingByParent(task)
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
}
@@ -986,7 +913,6 @@ export class BackgroundManager {
if (reason) {
task.error = reason
}
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "cancelled", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
@@ -1014,10 +940,6 @@ export class BackgroundManager {
}
if (options?.skipNotification) {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1135,7 +1057,6 @@ export class BackgroundManager {
// Atomically mark as completed to prevent race conditions
task.status = "completed"
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
// Release concurrency BEFORE any async operations to prevent slot leaks
if (task.concurrencyKey) {
@@ -1257,10 +1178,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while loading messages; using messageDir fallback:", {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
}
const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
@@ -1283,7 +1205,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})
@@ -1294,13 +1215,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
})
} catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted while sending notification; continuing cleanup:", {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
} else {
log("[background-agent] Failed to send notification:", error)
return
}
log("[background-agent] Failed to send notification:", error)
}
if (allComplete) {
@@ -1360,24 +1281,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
return ""
}
private isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
private getSessionErrorMessage(properties: EventProperties): string | undefined {
const errorRaw = properties["error"]
if (!this.isRecord(errorRaw)) return undefined
const dataRaw = errorRaw["data"]
if (this.isRecord(dataRaw)) {
const message = dataRaw["message"]
if (typeof message === "string") return message
}
const message = errorRaw["message"]
return typeof message === "string" ? message : undefined
}
private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) {
if (task.status === "running") return true
@@ -1389,7 +1292,6 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
const now = Date.now()
for (const [taskId, task] of this.tasks.entries()) {
const wasPending = task.status === "pending"
const timestamp = task.status === "pending"
? task.queuedAt?.getTime()
: task.startedAt?.getTime()
@@ -1414,26 +1316,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
// Clean up pendingByParent to prevent stale entries
this.cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
? `${task.model.providerID}/${task.model.modelID}`
: task.agent
const queue = this.queuesByKey.get(key)
if (queue) {
const index = queue.findIndex((item) => item.task.id === taskId)
if (index !== -1) {
queue.splice(index, 1)
if (queue.length === 0) {
this.queuesByKey.delete(key)
}
}
}
}
this.clearNotificationsForTask(taskId)
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
this.tasks.delete(taskId)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
@@ -1459,55 +1342,24 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
private async checkAndInterruptStaleTasks(
allStatuses: Record<string, { type: string }> = {},
): Promise<void> {
private async checkAndInterruptStaleTasks(): Promise<void> {
const staleTimeoutMs = this.config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const messageStalenessMs = this.config?.messageStalenessTimeoutMs ?? DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS
const now = Date.now()
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
if (!task.progress?.lastUpdate) continue
const startedAt = task.startedAt
const sessionID = task.sessionID
if (!startedAt || !sessionID) continue
const sessionStatus = allStatuses[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && sessionStatus !== "idle"
const runtime = now - startedAt.getTime()
if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue
if (runtime <= messageStalenessMs) continue
const staleMinutes = Math.round(runtime / 60000)
task.status = "cancelled"
task.error = `Stale timeout (no activity for ${staleMinutes}min since start)`
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
try {
await this.enqueueNotificationForParent(task.parentSessionID, () => this.notifyParentSession(task))
} catch (err) {
log("[background-agent] Error in notifyParentSession for stale task:", { taskId: task.id, error: err })
}
continue
}
if (sessionIsRunning) continue
if (runtime < MIN_RUNTIME_BEFORE_STALE_MS) continue
const timeSinceLastUpdate = now - task.progress.lastUpdate.getTime()
if (timeSinceLastUpdate <= staleTimeoutMs) continue
if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
@@ -1520,7 +1372,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.concurrencyKey = undefined
}
this.client.session.abort({ path: { id: sessionID } }).catch(() => {})
this.client.session.abort({
path: { id: sessionID },
}).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
try {
@@ -1533,12 +1388,11 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
await this.checkAndInterruptStaleTasks()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
await this.checkAndInterruptStaleTasks(allStatuses)
for (const task of this.tasks.values()) {
if (task.status !== "running") continue
@@ -1548,6 +1402,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
// Don't skip if session not in status - fall through to message-based detection
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
@@ -1569,16 +1424,94 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
continue
}
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
const messagesResult = await this.client.session.messages({
path: { id: sessionID },
})
if (!messagesResult.error && messagesResult.data) {
const messages = messagesResult.data as Array<{
info?: { role?: string }
parts?: Array<{ type?: string; tool?: string; name?: string; text?: string }>
}>
const assistantMsgs = messages.filter(
(m) => m.info?.role === "assistant"
)
let toolCalls = 0
let lastTool: string | undefined
let lastMessage: string | undefined
for (const msg of assistantMsgs) {
const parts = msg.parts ?? []
for (const part of parts) {
if (part.type === "tool_use" || part.tool) {
toolCalls++
lastTool = part.tool || part.name || "unknown"
}
if (part.type === "text" && part.text) {
lastMessage = part.text
}
}
}
if (!task.progress) {
task.progress = { toolCalls: 0, lastUpdate: new Date() }
}
task.progress.toolCalls = toolCalls
task.progress.lastTool = lastTool
task.progress.lastUpdate = new Date()
if (lastMessage) {
task.progress.lastMessage = lastMessage
task.progress.lastMessageAt = new Date()
}
// Stability detection: complete when message count unchanged for 3 polls
const currentMsgCount = messages.length
const startedAt = task.startedAt
if (!startedAt) continue
const elapsedMs = Date.now() - startedAt.getTime()
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
if (task.lastMsgCount === currentMsgCount) {
task.stablePolls = (task.stablePolls ?? 0) + 1
if (task.stablePolls >= 3) {
// Re-fetch session status to confirm agent is truly idle
const recheckStatus = await this.client.session.status()
const recheckData = (recheckStatus.data ?? {}) as Record<string, { type: string }>
const currentStatus = recheckData[sessionID]
if (currentStatus?.type !== "idle") {
log("[background-agent] Stability reached but session not idle, resetting:", {
taskId: task.id,
sessionStatus: currentStatus?.type ?? "not_in_status"
})
task.stablePolls = 0
continue
}
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (!hasIncompleteTodos) {
await this.tryCompleteTask(task, "stability detection")
continue
}
}
} else {
task.stablePolls = 0
}
}
task.lastMsgCount = currentMsgCount
}
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}

View File

@@ -148,7 +148,6 @@ export async function notifyParentSession(args: {
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -71,7 +71,6 @@ export async function notifyParentSession(
noReply: !allComplete,
...(agent !== undefined ? { agent } : {}),
...(model !== undefined ? { model } : {}),
...(task.parentTools ? { tools: task.parentTools } : {}),
parts: [{ type: "text", text: notification }],
},
})

View File

@@ -34,7 +34,7 @@ export async function pollRunningTasks(args: {
tasks: Iterable<BackgroundTask>
client: OpencodeClient
pruneStaleTasksAndNotifications: () => void
checkAndInterruptStaleTasks: (statuses: Record<string, { type: string }>) => Promise<void>
checkAndInterruptStaleTasks: () => Promise<void>
validateSessionHasOutput: (sessionID: string) => Promise<boolean>
checkSessionTodos: (sessionID: string) => Promise<boolean>
tryCompleteTask: (task: BackgroundTask, source: string) => Promise<boolean>
@@ -54,12 +54,11 @@ export async function pollRunningTasks(args: {
} = args
pruneStaleTasksAndNotifications()
await checkAndInterruptStaleTasks()
const statusResult = await client.session.status()
const allStatuses = ((statusResult as { data?: unknown }).data ?? {}) as SessionStatusMap
await checkAndInterruptStaleTasks(allStatuses)
for (const task of tasks) {
if (task.status !== "running") continue

Some files were not shown because too many files have changed in this diff Show More