Compare commits

..

4 Commits

Author SHA1 Message Date
YeonGyu-Kim
ed92a05e59 fix: address cubic review issues - abort handling, error metadata, logger binding 2026-03-16 15:18:41 +09:00
YeonGyu-Kim
40f25fb07d task: review and document agent selection 2026-03-16 15:06:33 +09:00
YeonGyu-Kim
c073169949 task: add agent resolver regression tests 2026-03-16 15:06:33 +09:00
YeonGyu-Kim
96f4b3b56c task: implement sdk runner 2026-03-16 15:06:33 +09:00
123 changed files with 1979 additions and 4090 deletions

View File

@@ -59,39 +59,20 @@ jobs:
- name: Check if already published
id: check
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
# Convert platform name for output (replace - with _)
PLATFORM_KEY="${{ matrix.platform }}"
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
# Check oh-my-opencode
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
# Check oh-my-openagent
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
echo "oh-my-opencode-${{ matrix.platform }}@${VERSION}: ${OC_STATUS}"
echo "oh-my-openagent-${{ matrix.platform }}@${VERSION}: ${OA_STATUS}"
if [ "$OC_STATUS" = "200" ]; then
echo "skip_opencode=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_opencode=false" >> $GITHUB_OUTPUT
echo "→ oh-my-opencode-${{ matrix.platform }}@${VERSION} needs publishing"
fi
if [ "$OA_STATUS" = "200" ]; then
echo "skip_openagent=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_openagent=false" >> $GITHUB_OUTPUT
echo "→ oh-my-openagent-${{ matrix.platform }}@${VERSION} needs publishing"
fi
# Skip build only if BOTH are already published
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
fi
- name: Update version in package.json
@@ -226,38 +207,23 @@ jobs:
matrix:
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps:
- name: Check if already published
- name: Check if oh-my-opencode already published
id: check
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
if [ "$OC_STATUS" = "200" ]; then
echo "skip_opencode=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-opencode-${{ matrix.platform }}@${VERSION} already published"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
else
echo "skip_opencode=false" >> $GITHUB_OUTPUT
fi
if [ "$OA_STATUS" = "200" ]; then
echo "skip_openagent=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-openagent-${{ matrix.platform }}@${VERSION} already published"
else
echo "skip_openagent=false" >> $GITHUB_OUTPUT
fi
# Need artifact if either package needs publishing
if [ "$OC_STATUS" = "200" ] && [ "$OA_STATUS" = "200" ]; then
echo "skip_all=true" >> $GITHUB_OUTPUT
else
echo "skip_all=false" >> $GITHUB_OUTPUT
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Download artifact
id: download
if: steps.check.outputs.skip_all != 'true'
if: steps.check.outputs.skip != 'true'
continue-on-error: true
uses: actions/download-artifact@v4
with:
@@ -265,7 +231,7 @@ jobs:
path: .
- name: Extract artifact
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
@@ -281,13 +247,13 @@ jobs:
ls -la packages/${PLATFORM}/bin/
- uses: actions/setup-node@v4
if: steps.check.outputs.skip_all != 'true' && steps.download.outcome == 'success'
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Publish oh-my-opencode-${{ matrix.platform }}
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
- name: Publish ${{ matrix.platform }}
if: steps.check.outputs.skip != 'true' && steps.download.outcome == 'success'
run: |
cd packages/${{ matrix.platform }}
@@ -301,25 +267,3 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15
- name: Publish oh-my-openagent-${{ matrix.platform }}
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
run: |
cd packages/${{ matrix.platform }}
# Rename package for oh-my-openagent
jq --arg name "oh-my-openagent-${{ matrix.platform }}" \
--arg desc "Platform-specific binary for oh-my-openagent (${{ matrix.platform }})" \
'.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \
package.json > tmp.json && mv tmp.json package.json
TAG_ARG=""
if [ -n "${{ inputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ inputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15

View File

@@ -216,48 +216,6 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Check if oh-my-openagent already published
id: check-openagent
run: |
VERSION="${{ steps.version.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-openagent@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Publish oh-my-openagent
if: steps.check-openagent.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
# Update package name, version, and optionalDependencies for oh-my-openagent
jq --arg v "$VERSION" '
.name = "oh-my-openagent" |
.version = $v |
.optionalDependencies = (
.optionalDependencies | to_entries |
map(.key = (.key | sub("^oh-my-opencode-"; "oh-my-openagent-")) | .value = $v) |
from_entries
)
' package.json > tmp.json && mv tmp.json package.json
TAG_ARG=""
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG || echo "::warning::oh-my-openagent publish failed"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Restore package.json
if: steps.check-openagent.outputs.skip != 'true'
run: |
git checkout -- package.json
trigger-platform:
runs-on: ubuntu-latest
needs: publish-main

View File

@@ -1,122 +0,0 @@
# Pre-Publish BLOCK Issues: Fix ALL Before Release
Two independent pre-publish reviews (Opus 4.6 + GPT-5.4) both concluded **BLOCK -- do not publish**. You must fix ALL blocking issues below using UltraBrain parallel agents. Work TDD-style: write/update tests first, then fix, verify tests pass.
## Strategy
Use ultrawork (ulw) to spawn UltraBrain agents in parallel. Each UB agent gets a non-overlapping scope. After all agents complete, run bun test to verify everything passes. Commit atomically per fix group.
---
## CRITICAL BLOCKERS (must fix -- 6 items)
### C1: Hashline Backward Compatibility
**Problem:** Strict whitespace hashing in hashline changes LINE#ID values for indented lines. Breaks existing anchors in cached/persisted edit operations.
**Fix:** Add a compatibility shim -- when lookup by new hash fails, fall back to legacy hash (without strict whitespace). Or version the hash format.
**Files:** Look for hashline-related files in src/tools/ or src/shared/
### C2: OpenAI-Only Model Catalog Broken with OpenCode-Go
**Problem:** isOpenAiOnlyAvailability() does not exclude availability.opencodeGo. When OpenCode-Go is present, OpenAI-only detection is wrong -- models get misrouted.
**Fix:** Add !availability.opencodeGo check to isOpenAiOnlyAvailability().
**Files:** Model/provider system files -- search for isOpenAiOnlyAvailability
### C3: CLI/Runtime Model Table Divergence
**Problem:** Model tables disagree between CLI install-time and runtime:
- ultrabrain: gpt-5.3-codex in CLI vs gpt-5.4 in runtime
- atlas: claude-sonnet-4-5 in CLI vs claude-sonnet-4-6 in runtime
- unspecified-high also diverges
**Fix:** Reconcile all model tables. Pick the correct model for each and make CLI + runtime match.
**Files:** Search for model table definitions, agent configs, CLI model references
### C4: atlas/metis/sisyphus-junior Missing OpenAI Fallbacks
**Problem:** These agents can resolve to opencode/glm-4.7-free or undefined in OpenAI-only environments. No valid OpenAI fallback paths exist.
**Fix:** Add valid OpenAI model fallback paths for all agents that need them.
**Files:** Agent config/model resolution code
### C5: model_fallback Default Mismatch
**Problem:** Schema and docs say model_fallback defaults to false, but runtime treats unset as true. Silent behavior change for all users.
**Fix:** Align -- either update schema/docs to say true, or fix runtime to default to false. Check what the intended behavior is from git history.
**Files:** Schema definition, runtime config loading
### C6: background_output Default Changed
**Problem:** background_output now defaults to full_session=true. Old callers get different output format without code changes.
**Fix:** Either document this change clearly, or restore old default and make full_session opt-in.
**Files:** Background output handling code
---
## HIGH PRIORITY (strongly recommended -- 4 items)
### H1: Runtime Fallback session-status-handler Race
**Problem:** When fallback model is already pending, the handler cannot advance the chain on subsequent cooldown events.
**Fix:** Allow override like message-update-handler does.
**Files:** Search for session-status-handler, message-update-handler
### H2: Atlas Final-Wave Approval Gate Logic
**Problem:** Approval gate logic does not match real Prometheus plan structure (nested checkboxes, parallel execution). Trigger logic is wrong.
**Fix:** Update to handle real plan structures.
**Files:** Atlas agent code, approval gate logic
### H3: delegate-task-english-directive Dead Code
**Problem:** Not dispatched from tool-execute-before.ts + wrong hook signature. Either wire properly or remove entirely.
**Fix:** Remove if not needed (cleaner). If needed, fix dispatch + signature.
**Files:** src/hooks/, tool-execute-before.ts
### H4: Auto-Slash-Command Session-Lifetime Dedup
**Problem:** Dedup uses session lifetime, suppressing legitimate repeated identical commands.
**Fix:** Change to short TTL (e.g., 30 seconds) instead of session lifetime.
**Files:** Slash command handling code
---
## ADDITIONAL BLOCKERS FROM GPT-5.4 REVIEW
### G1: Package Identity Split-Brain
**Problem:** Installer writes oh-my-openagent but doctor, auto-update, version lookup, publish workflow still reference oh-my-opencode. Half-migrated state.
**Fix:** Audit ALL references to package name. Either complete the migration consistently or revert to single name for this release.
**Files:** Installer, doctor, auto-update, version lookup, publish workflow -- grep for both package names
### G2: OpenCode-Go --opencode-go Value Validation
**Problem:** No validation for --opencode-go CLI value. No detection of existing OpenCode-Go installations.
**Fix:** Add value validation + existing install detection.
**Files:** CLI option handling code
### G3: Skill/Hook Reference Errors
**Problem:**
- work-with-pr references non-existent git tool category
- github-triage references TaskCreate/TaskUpdate which are not real tool names
**Fix:** Fix tool references to use actual tool names.
**Files:** Skill definition files in .opencode/skills/
### G4: Stale Context-Limit Cache
**Problem:** Shared context-limit resolver caches provider config. When config changes, stale removed limits persist and corrupt compaction/truncation decisions.
**Fix:** Add cache invalidation when provider config changes, or make the resolver stateless.
**Files:** Context-limit resolver, compaction code
### G5: disabled_hooks Schema vs Runtime Contract Mismatch
**Problem:** Schema is strict (rejects unknown hook names) but runtime is permissive (ignores unknown). Contract disagreement.
**Fix:** Align -- either make both strict or both permissive.
**Files:** Hook schema definition, runtime hook loading
---
## EXECUTION INSTRUCTIONS
1. Spawn UltraBrain agents to fix these in parallel -- group by file proximity:
- UB-1: C1 (hashline) + H4 (slash-command dedup)
- UB-2: C2 + C3 + C4 (model/provider system) + G2
- UB-3: C5 + C6 (config defaults) + G5
- UB-4: H1 + H2 (runtime handlers + Atlas gate)
- UB-5: H3 + G3 (dead code + skill references)
- UB-6: G1 (package identity -- full audit)
- UB-7: G4 (context-limit cache)
2. Each UB agent MUST:
- Write or update tests FIRST (TDD)
- Implement the fix
- Run bun test on affected test files
- Commit with descriptive message
3. After all UB agents complete, run full bun test to verify no regressions.
ulw

View File

@@ -1,3 +1,9 @@
> [!WARNING]
> **TEMP NOTICE (This Week): Reduced Maintainer Availability**
>
> Core maintainer Q got injured, so issue/PR responses and releases may be delayed this week.
> Thank you for your patience and support.
> [!NOTE]
>
> [![Sisyphus Labs - Sisyphus is the agent that codes like your team.](./.github/assets/sisyphuslabs.png?v=2)](https://sisyphuslabs.ai)

View File

@@ -43,7 +43,57 @@
"disabled_hooks": {
"type": "array",
"items": {
"type": "string"
"type": "string",
"enum": [
"gpt-permission-continuation",
"todo-continuation-enforcer",
"context-window-monitor",
"session-recovery",
"session-notification",
"comment-checker",
"tool-output-truncator",
"question-label-truncator",
"directory-agents-injector",
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"model-fallback",
"anthropic-context-window-limit-recovery",
"preemptive-compaction",
"rules-injector",
"background-notification",
"auto-update-checker",
"startup-toast",
"keyword-detector",
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"thinking-block-validator",
"ralph-loop",
"category-skill-reminder",
"compaction-context-injector",
"compaction-todo-preserver",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
"json-error-recovery",
"delegate-task-retry",
"prometheus-md-only",
"sisyphus-junior-notepad",
"no-sisyphus-gpt",
"no-hephaestus-non-gpt",
"start-work",
"atlas",
"unstable-agent-babysitter",
"task-resume-info",
"stop-continuation-guard",
"tasks-todowrite-disabler",
"runtime-fallback",
"write-existing-file-guard",
"anthropic-effort",
"hashline-read-enhancer",
"read-image-resizer"
]
}
},
"disabled_commands": {
@@ -3699,35 +3749,6 @@
"syncPollTimeoutMs": {
"type": "number",
"minimum": 60000
},
"maxToolCalls": {
"type": "integer",
"minimum": 10,
"maximum": 9007199254740991
},
"circuitBreaker": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean"
},
"maxToolCalls": {
"type": "integer",
"minimum": 10,
"maximum": 9007199254740991
},
"windowSize": {
"type": "integer",
"minimum": 5,
"maximum": 9007199254740991
},
"repetitionThresholdPercent": {
"type": "number",
"exclusiveMinimum": 0,
"maximum": 100
}
},
"additionalProperties": false
}
},
"additionalProperties": false
@@ -3906,4 +3927,4 @@
}
},
"additionalProperties": false
}
}

View File

@@ -5,7 +5,7 @@
"": {
"name": "hashline-edit-benchmark",
"dependencies": {
"@ai-sdk/openai-compatible": "^2.0.35",
"@friendliai/ai-provider": "^1.0.9",
"ai": "^6.0.94",
"zod": "^4.1.0",
},
@@ -14,11 +14,13 @@
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.55", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7xMeTJnCjwRwXKVCiv4Ly4qzWvDuW3+W1WIV0X1EFu6W83d4mEhV9bFArto10MeTw40ewuDjrbrZd21mXKohkw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-g3wA57IAQFb+3j4YuFndgkUdXyRETZVvbfAWM+UX7bZSxA3xjes0v3XKgIdKdekPtDGsh4ZX2byHD0gJIMPfiA=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iTjumHf1/u4NhjXYFn/aONM2GId3/o7J1Lp5ql8FCbgIMyRwrmanR5xy1S3aaVkfTscuDvLTzWiy1mAbGzK3nQ=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg=="],
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"@friendliai/ai-provider": ["@friendliai/ai-provider@1.1.4", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.30", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.12" } }, "sha512-9TU4B1QFqPhbkONjI5afCF7Ox4jOqtGg1xw8mA9QHZdtlEbZxU+mBNvMPlI5pU5kPoN6s7wkXmFmxpID+own1A=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
@@ -33,9 +35,5 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.15", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8XiKWbemmCbvNN0CLR9u3PQiet4gtEVIrX4zzLxnCj06AwsEDJwJVBbKrEI4t6qE8XRSIvU2irka0dcpziKW6w=="],
}
}

View File

@@ -3,17 +3,16 @@ import { readFile, writeFile, mkdir } from "node:fs/promises"
import { join, dirname } from "node:path"
import { stepCountIs, streamText, type CoreMessage } from "ai"
import { tool } from "ai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createFriendli } from "@friendliai/ai-provider"
import { z } from "zod"
import { formatHashLines } from "../../src/tools/hashline-edit/hash-computation"
import { normalizeHashlineEdits } from "../../src/tools/hashline-edit/normalize-edits"
import { applyHashlineEditsWithReport } from "../../src/tools/hashline-edit/edit-operations"
import { canonicalizeFileText, restoreFileText } from "../../src/tools/hashline-edit/file-text-canonicalization"
import { HASHLINE_EDIT_DESCRIPTION } from "../../src/tools/hashline-edit/tool-description"
import { formatHashLines } from "../src/tools/hashline-edit/hash-computation"
import { normalizeHashlineEdits } from "../src/tools/hashline-edit/normalize-edits"
import { applyHashlineEditsWithReport } from "../src/tools/hashline-edit/edit-operations"
import { canonicalizeFileText, restoreFileText } from "../src/tools/hashline-edit/file-text-canonicalization"
const DEFAULT_MODEL = "minimax-m2.5-free"
const DEFAULT_MODEL = "MiniMaxAI/MiniMax-M2.5"
const MAX_STEPS = 50
const sessionId = `hashline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const sessionId = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const emit = (event: Record<string, unknown>) =>
console.log(JSON.stringify({ sessionId, timestamp: new Date().toISOString(), ...event }))
@@ -34,7 +33,7 @@ function parseArgs(): { prompt: string; modelId: string } {
// --no-translate, --think consumed silently
}
if (!prompt) {
console.error("Usage: bun run tests/hashline/headless.ts -p <prompt> [-m <model>]")
console.error("Usage: bun run benchmarks/headless.ts -p <prompt> [-m <model>]")
process.exit(1)
}
return { prompt, modelId }
@@ -58,7 +57,7 @@ const readFileTool = tool({
})
const editFileTool = tool({
description: HASHLINE_EDIT_DESCRIPTION,
description: "Edit a file using hashline anchors (LINE#ID format)",
inputSchema: z.object({
path: z.string(),
edits: z.array(
@@ -117,12 +116,8 @@ const editFileTool = tool({
async function run() {
const { prompt, modelId } = parseArgs()
const provider = createOpenAICompatible({
name: "hashline-test",
baseURL: process.env.HASHLINE_TEST_BASE_URL ?? "https://quotio.mengmota.com/v1",
apiKey: process.env.HASHLINE_TEST_API_KEY ?? "quotio-local-60A613FE-DB74-40FF-923E-A14151951E5D",
})
const model = provider.chatModel(modelId)
const friendli = createFriendli({ apiKey: process.env.FRIENDLI_TOKEN! })
const model = friendli(modelId)
const tools = { read_file: readFileTool, edit_file: editFileTool }
emit({ type: "user", content: prompt })
@@ -130,8 +125,7 @@ async function run() {
const messages: CoreMessage[] = [{ role: "user", content: prompt }]
const system =
"You are a code editing assistant. Use read_file to read files and edit_file to edit them. " +
"Always read a file before editing it to get fresh LINE#ID anchors.\n\n" +
"edit_file tool description:\n" + HASHLINE_EDIT_DESCRIPTION
"Always read a file before editing it to get fresh LINE#ID anchors."
for (let step = 0; step < MAX_STEPS; step++) {
const stream = streamText({
@@ -167,7 +161,6 @@ async function run() {
...(isError ? { error: output } : {}),
})
break
}
}
}
@@ -198,4 +191,3 @@ run()
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2)
console.error(`[headless] Completed in ${elapsed}s`)
})

18
benchmarks/package.json Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "hashline-edit-benchmark",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Hashline edit tool benchmark using Vercel AI SDK with FriendliAI provider",
"scripts": {
"bench:basic": "bun run test-edit-ops.ts",
"bench:edge": "bun run test-edge-cases.ts",
"bench:multi": "bun run test-multi-model.ts",
"bench:all": "bun run bench:basic && bun run bench:edge"
},
"dependencies": {
"@friendliai/ai-provider": "^1.0.9",
"ai": "^6.0.94",
"zod": "^4.1.0"
}
}

View File

@@ -14,7 +14,10 @@ import { resolve } from "node:path";
// ── Models ────────────────────────────────────────────────────
const MODELS = [
{ id: "minimax-m2.5-free", short: "M2.5-Free" },
{ id: "MiniMaxAI/MiniMax-M2.5", short: "M2.5" },
// { id: "MiniMaxAI/MiniMax-M2.1", short: "M2.1" }, // masked: slow + timeout-prone
// { id: "zai-org/GLM-5", short: "GLM-5" }, // masked: API 503
{ id: "zai-org/GLM-4.7", short: "GLM-4.7" },
];
// ── CLI args ──────────────────────────────────────────────────

View File

@@ -64,8 +64,8 @@ These agents have Claude-optimized prompts — long, detailed, mechanics-driven.
| Agent | Role | Fallback Chain | Notes |
| ------------ | ----------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → Kimi K2.5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi available through multiple providers. |
| **Metis** | Plan gap analyzer | Claude Opus → GPT-5.4 → opencode-go/glm-5 → K2P5 | Claude preferred. GPT-5.4 as secondary before GLM-5 fallback. |
| **Sisyphus** | Main orchestrator | Claude Opus → opencode-go/kimi-k2.5 → K2P5 → GPT-5.4 → GLM-5 → Big Pickle | Claude-family first. GPT-5.4 has dedicated prompt support. Kimi/GLM as intermediate fallbacks. |
| **Metis** | Plan gap analyzer | Claude Opus → opencode-go/glm-5 → K2P5 | Claude preferred. Uses opencode-go for reliable GLM-5 access. |
### Dual-Prompt Agents → Claude preferred, GPT supported
@@ -74,7 +74,7 @@ These agents ship separate prompts for Claude and GPT families. They auto-detect
| Agent | Role | Fallback Chain | Notes |
| -------------- | ----------------- | -------------------------------------- | -------------------------------------------------------------------- |
| **Prometheus** | Strategic planner | Claude Opus → GPT-5.4 → opencode-go/glm-5 → Gemini 3.1 Pro | Interview-mode planning. GPT prompt is compact and principle-driven. |
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 | Claude first, opencode-go as intermediate, GPT-5.4 as last resort. |
| **Atlas** | Todo orchestrator | Claude Sonnet → opencode-go/kimi-k2.5 | Claude first, opencode-go as the current fallback path. |
### Deep Specialists → GPT
@@ -82,9 +82,9 @@ These agents are built for GPT's principle-driven style. Their prompts assume au
| Agent | Role | Fallback Chain | Notes |
| -------------- | ----------------------- | -------------------------------------- | ------------------------------------------------ |
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex → GPT-5.4 (Copilot) | Requires GPT access. GPT-5.4 via Copilot as fallback. The craftsman. |
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 | Read-only high-IQ consultation. |
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro → opencode-go/glm-5 | Verification and plan review. GPT-5.4 uses xhigh variant. |
| **Hephaestus** | Autonomous deep worker | GPT-5.3 Codex only | No fallback. Requires GPT access. The craftsman. |
| **Oracle** | Architecture consultant | GPT-5.4 → Gemini 3.1 Pro → Claude Opus | Read-only high-IQ consultation. |
| **Momus** | Ruthless reviewer | GPT-5.4 → Claude Opus → Gemini 3.1 Pro | Verification and plan review. |
### Utility Runners → Speed over Intelligence
@@ -95,7 +95,6 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
| **Librarian** | Docs/code search | opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
---
@@ -120,7 +119,8 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
| Model | Strengths |
| ----------------- | ----------------------------------------------------------------------------------------------- |
| **GPT-5.3 Codex** | Deep coding powerhouse. Autonomous exploration. Required for Hephaestus. |
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle, Momus, and a key fallback for Prometheus / Atlas. Uses xhigh variant for Momus. |
| **GPT-5.4** | High intelligence, strategic reasoning. Default for Oracle. |
| **GPT-5.4** | Strong principle-driven reasoning. Default for Momus and a key fallback for Prometheus / Atlas. |
| **GPT-5-Nano** | Ultra-cheap, fast. Good for simple utility tasks. |
### Other Models
@@ -166,14 +166,14 @@ When agents delegate work, they don't pick a model name — they pick a **catego
| Category | When Used | Fallback Chain |
| -------------------- | -------------------------- | -------------------------------------------- |
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus → opencode-go/glm-5 → K2P5 |
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 |
| `visual-engineering` | Frontend, UI, CSS, design | Gemini 3.1 Pro → GLM 5 → Claude Opus |
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus |
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash |
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |
| `quick` | Simple, fast tasks | Claude Haiku → Gemini Flash → GPT-5-Nano |
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 (high) → GLM 5 → K2P5 |
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → Gemini Flash |
| `writing` | Text, docs, prose | Gemini Flash → Claude Sonnet |
See the [Orchestration System Guide](./orchestration.md) for how agents dispatch tasks to categories.

View File

@@ -22,6 +22,7 @@
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
"build:sdk": "cd packages/sdk && bun run build",
"build:all": "bun run build && bun run build:binaries",
"build:binaries": "bun run script/build-binaries.ts",
"build:schema": "bun run script/build-schema.ts",
@@ -30,7 +31,9 @@
"postinstall": "node postinstall.mjs",
"prepublishOnly": "bun run clean && bun run build",
"typecheck": "tsc --noEmit",
"test": "bun test"
"typecheck:sdk": "cd packages/sdk && bun run typecheck",
"test": "bun test",
"test:sdk": "cd packages/sdk && bun run test"
},
"keywords": [
"opencode",

27
packages/sdk/README.md Normal file
View File

@@ -0,0 +1,27 @@
# @oh-my-openagent/sdk
Programmatic runner for starting or attaching to an OpenCode server, running oh-my-openagent sessions, and consuming normalized lifecycle events.
## `run()`
```ts
import { createOmoRunner } from "@oh-my-openagent/sdk"
const runner = createOmoRunner({ directory: process.cwd(), agent: "prometheus" })
const result = await runner.run("Plan the next release")
await runner.close()
```
## `stream()`
```ts
import { createOmoRunner } from "@oh-my-openagent/sdk"
const runner = createOmoRunner({ directory: process.cwd() })
for await (const event of runner.stream("Investigate the build failure")) {
console.log(event.type)
}
await runner.close()
```

23
packages/sdk/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@oh-my-openagent/sdk",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "rm -rf dist && bun build ./src/index.ts --outdir dist --target bun --format esm && cp ./src/*.d.ts ./dist/",
"test": "bun test",
"typecheck": "tsc -p tsconfig.json --noEmit"
}
}

View File

@@ -0,0 +1,3 @@
import type { CreateOmoRunnerOptions, OmoRunner } from "./types"
export declare function createOmoRunner(options: CreateOmoRunnerOptions): OmoRunner

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
import type { ServerConnection } from "../../../src/cli/run/types"
const mockCreateServerConnection = mock(
async (): Promise<ServerConnection> => ({
client: {} as never,
cleanup: mock(() => {}),
}),
)
const mockExecuteRunSession = mock(async (_options: unknown) => ({
exitCode: 0,
sessionId: "ses_runner",
result: {
sessionId: "ses_runner",
success: true,
durationMs: 10,
messageCount: 1,
summary: "done",
},
}))
mock.module("../../../src/cli/run/server-connection", () => ({
createServerConnection: mockCreateServerConnection,
}))
mock.module("../../../src/cli/run/run-engine", () => ({
executeRunSession: mockExecuteRunSession,
}))
const { createOmoRunner } = await import("./create-omo-runner")
describe("createOmoRunner", () => {
beforeEach(() => {
mockCreateServerConnection.mockClear()
mockExecuteRunSession.mockClear()
})
it("reuses the same connection and enables question-aware execution", async () => {
const runner = createOmoRunner({
directory: "/repo",
agent: "atlas",
})
const first = await runner.run("first")
const second = await runner.run("second", { agent: "prometheus" })
expect(first.summary).toBe("done")
expect(second.summary).toBe("done")
expect(mockCreateServerConnection).toHaveBeenCalledTimes(1)
expect(mockExecuteRunSession).toHaveBeenNthCalledWith(1, expect.objectContaining({
directory: "/repo",
agent: "atlas",
questionPermission: "allow",
questionToolEnabled: true,
renderOutput: false,
}))
expect(mockExecuteRunSession).toHaveBeenNthCalledWith(2, expect.objectContaining({
agent: "prometheus",
}))
await runner.close()
})
it("streams normalized events", async () => {
mockExecuteRunSession.mockImplementationOnce(async (options: { eventObserver?: { onEvent?: (event: unknown) => Promise<void> } }) => {
await options.eventObserver?.onEvent?.({
type: "session.started",
sessionId: "ses_runner",
agent: "Atlas (Plan Executor)",
resumed: false,
})
await options.eventObserver?.onEvent?.({
type: "session.completed",
sessionId: "ses_runner",
result: {
sessionId: "ses_runner",
success: true,
durationMs: 10,
messageCount: 1,
summary: "done",
},
})
return {
exitCode: 0,
sessionId: "ses_runner",
result: {
sessionId: "ses_runner",
success: true,
durationMs: 10,
messageCount: 1,
summary: "done",
},
}
})
const runner = createOmoRunner({ directory: "/repo" })
const seenTypes: string[] = []
for await (const event of runner.stream("stream")) {
seenTypes.push(event.type)
}
expect(seenTypes).toEqual(["session.started", "session.completed"])
await runner.close()
})
})

View File

@@ -0,0 +1,189 @@
import { createServerConnection } from "../../../src/cli/run/server-connection"
import { executeRunSession } from "../../../src/cli/run/run-engine"
import type { RunEventObserver, ServerConnection } from "../../../src/cli/run/types"
import type {
CreateOmoRunnerOptions,
OmoRunInvocationOptions,
OmoRunner,
RunResult,
StreamEvent,
} from "./types"
class AsyncEventQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = []
private readonly waiters: Array<(value: IteratorResult<T>) => void> = []
private closed = false
push(value: T): void {
if (this.closed) return
const waiter = this.waiters.shift()
if (waiter) {
waiter({ value, done: false })
return
}
this.values.push(value)
}
close(): void {
if (this.closed) return
this.closed = true
while (this.waiters.length > 0) {
const waiter = this.waiters.shift()
waiter?.({ value: undefined, done: true })
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
const value = this.values.shift()
if (value !== undefined) {
return Promise.resolve({ value, done: false })
}
if (this.closed) {
return Promise.resolve({ value: undefined, done: true })
}
return new Promise<IteratorResult<T>>((resolve) => {
this.waiters.push(resolve)
})
},
}
}
}
export function createOmoRunner(options: CreateOmoRunnerOptions): OmoRunner {
const {
directory,
agent,
port,
model,
attach,
includeRawEvents = false,
onIdle,
onQuestion,
onComplete,
onError,
} = options
let connectionPromise: Promise<ServerConnection> | null = null
let connectionController: AbortController | null = null
let closed = false
let activeRun: Promise<unknown> | null = null
const silentLogger = {
log: () => {},
error: () => {},
}
const ensureConnection = async (): Promise<ServerConnection> => {
if (closed) {
throw new Error("Runner is closed")
}
if (connectionPromise === null) {
connectionController = new AbortController()
connectionPromise = createServerConnection({
port,
attach,
signal: connectionController.signal,
logger: silentLogger,
})
}
return await connectionPromise
}
const createObserver = (
queue?: AsyncEventQueue<StreamEvent>,
): RunEventObserver => ({
includeRawEvents,
onEvent: async (event) => {
queue?.push(event as StreamEvent)
},
onIdle,
onQuestion,
onComplete,
onError,
})
const runOnce = async (
prompt: string,
invocationOptions: OmoRunInvocationOptions | undefined,
observer: RunEventObserver,
): Promise<RunResult> => {
if (activeRun !== null) {
throw new Error("Runner already has an active operation")
}
const connection = await ensureConnection()
const execution = executeRunSession({
client: connection.client,
message: prompt,
directory,
agent: invocationOptions?.agent ?? agent,
model: invocationOptions?.model ?? model,
sessionId: invocationOptions?.sessionId,
questionPermission: "allow",
questionToolEnabled: true,
renderOutput: false,
logger: silentLogger,
eventObserver: observer,
signal: invocationOptions?.signal,
})
activeRun = execution
const abortHandler = () => {
void observer.onError?.({
type: "session.error",
sessionId: invocationOptions?.sessionId ?? "",
error: "Aborted by caller",
})
}
invocationOptions?.signal?.addEventListener("abort", abortHandler, { once: true })
try {
const { result } = await execution
return result
} finally {
invocationOptions?.signal?.removeEventListener("abort", abortHandler)
activeRun = null
}
}
return {
async run(prompt, invocationOptions) {
return await runOnce(prompt, invocationOptions, createObserver())
},
stream(prompt, invocationOptions) {
const queue = new AsyncEventQueue<StreamEvent>()
const execution = runOnce(prompt, invocationOptions, createObserver(queue))
.catch((error) => {
queue.push({
type: "session.error",
sessionId: invocationOptions?.sessionId ?? "",
error: error instanceof Error ? error.message : String(error),
})
})
.finally(() => {
queue.close()
})
return {
async *[Symbol.asyncIterator]() {
try {
for await (const event of queue) {
yield event
}
} finally {
await execution
}
},
}
},
async close() {
closed = true
connectionController?.abort()
const connection = await connectionPromise
connection?.cleanup()
connectionPromise = null
connectionController = null
},
}
}

8
packages/sdk/src/index.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
export { createOmoRunner } from "./create-omo-runner"
export type {
CreateOmoRunnerOptions,
OmoRunInvocationOptions,
OmoRunner,
RunResult,
StreamEvent,
} from "./types"

View File

@@ -0,0 +1,8 @@
export { createOmoRunner } from "./create-omo-runner"
export type {
CreateOmoRunnerOptions,
OmoRunInvocationOptions,
OmoRunner,
RunResult,
StreamEvent,
} from "./types"

95
packages/sdk/src/types.d.ts vendored Normal file
View File

@@ -0,0 +1,95 @@
export interface RunResult {
sessionId: string
success: boolean
durationMs: number
messageCount: number
summary: string
}
export type StreamEvent =
| {
type: "session.started"
sessionId: string
agent: string
resumed: boolean
model?: { providerID: string; modelID: string }
}
| {
type: "message.delta"
sessionId: string
messageId?: string
partId?: string
delta: string
}
| {
type: "message.completed"
sessionId: string
messageId?: string
partId?: string
text: string
}
| {
type: "tool.started"
sessionId: string
toolName: string
input?: unknown
}
| {
type: "tool.completed"
sessionId: string
toolName: string
output?: string
status: "completed" | "error"
}
| {
type: "session.idle"
sessionId: string
}
| {
type: "session.question"
sessionId: string
toolName: string
input?: unknown
question?: string
}
| {
type: "session.completed"
sessionId: string
result: RunResult
}
| {
type: "session.error"
sessionId: string
error: string
}
| {
type: "raw"
sessionId: string
payload: unknown
}
export interface OmoRunInvocationOptions {
sessionId?: string
signal?: AbortSignal
agent?: string
model?: string
}
export interface CreateOmoRunnerOptions {
directory: string
agent?: string
port?: number
model?: string
attach?: string
includeRawEvents?: boolean
onIdle?: (event: Extract<StreamEvent, { type: "session.idle" }>) => void | Promise<void>
onQuestion?: (event: Extract<StreamEvent, { type: "session.question" }>) => void | Promise<void>
onComplete?: (event: Extract<StreamEvent, { type: "session.completed" }>) => void | Promise<void>
onError?: (event: Extract<StreamEvent, { type: "session.error" }>) => void | Promise<void>
}
export interface OmoRunner {
run(prompt: string, options?: OmoRunInvocationOptions): Promise<RunResult>
stream(prompt: string, options?: OmoRunInvocationOptions): AsyncIterable<StreamEvent>
close(): Promise<void>
}

95
packages/sdk/src/types.ts Normal file
View File

@@ -0,0 +1,95 @@
export interface RunResult {
sessionId: string
success: boolean
durationMs: number
messageCount: number
summary: string
}
export type StreamEvent =
| {
type: "session.started"
sessionId: string
agent: string
resumed: boolean
model?: { providerID: string; modelID: string }
}
| {
type: "message.delta"
sessionId: string
messageId?: string
partId?: string
delta: string
}
| {
type: "message.completed"
sessionId: string
messageId?: string
partId?: string
text: string
}
| {
type: "tool.started"
sessionId: string
toolName: string
input?: unknown
}
| {
type: "tool.completed"
sessionId: string
toolName: string
output?: string
status: "completed" | "error"
}
| {
type: "session.idle"
sessionId: string
}
| {
type: "session.question"
sessionId: string
toolName: string
input?: unknown
question?: string
}
| {
type: "session.completed"
sessionId: string
result: RunResult
}
| {
type: "session.error"
sessionId: string
error: string
}
| {
type: "raw"
sessionId: string
payload: unknown
}
export interface OmoRunInvocationOptions {
sessionId?: string
signal?: AbortSignal
agent?: string
model?: string
}
export interface CreateOmoRunnerOptions {
directory: string
agent?: string
port?: number
model?: string
attach?: string
includeRawEvents?: boolean
onIdle?: (event: Extract<StreamEvent, { type: "session.idle" }>) => void | Promise<void>
onQuestion?: (event: Extract<StreamEvent, { type: "session.question" }>) => void | Promise<void>
onComplete?: (event: Extract<StreamEvent, { type: "session.completed" }>) => void | Promise<void>
onError?: (event: Extract<StreamEvent, { type: "session.error" }>) => void | Promise<void>
}
export interface OmoRunner {
run(prompt: string, options?: OmoRunInvocationOptions): Promise<RunResult>
stream(prompt: string, options?: OmoRunInvocationOptions): AsyncIterable<StreamEvent>
close(): Promise<void>
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"lib": ["ESNext"],
"types": ["bun-types"],
"rootDir": "../.."
},
"include": [
"src/**/*.ts",
"../../src/**/*.ts"
],
"exclude": [
"dist",
"**/*.test.ts",
"../../src/**/*.test.ts"
]
}

View File

@@ -2207,38 +2207,6 @@
"created_at": "2026-03-16T04:55:10Z",
"repoId": 1108837393,
"pullRequestNo": 2604
},
{
"name": "gxlife",
"id": 110413359,
"comment_id": 4068427047,
"created_at": "2026-03-16T15:17:01Z",
"repoId": 1108837393,
"pullRequestNo": 2625
},
{
"name": "HaD0Yun",
"id": 102889891,
"comment_id": 4073195308,
"created_at": "2026-03-17T08:27:45Z",
"repoId": 1108837393,
"pullRequestNo": 2640
},
{
"name": "tad-hq",
"id": 213478119,
"comment_id": 4077697128,
"created_at": "2026-03-17T20:07:09Z",
"repoId": 1108837393,
"pullRequestNo": 2655
},
{
"name": "ogormans-deptstack",
"id": 208788555,
"comment_id": 4077893096,
"created_at": "2026-03-17T20:42:42Z",
"repoId": 1108837393,
"pullRequestNo": 2656
}
]
}

View File

@@ -5,60 +5,60 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"hephaestus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"librarian": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"sisyphus-junior": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
"artistry": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"deep": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"quick": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
}
@@ -83,7 +83,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
"variant": "max",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "anthropic/claude-opus-4-6",
@@ -145,7 +145,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
"variant": "max",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "anthropic/claude-opus-4-6",
@@ -366,20 +366,20 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
"metis": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "google/gemini-3.1-pro-preview",
@@ -389,7 +389,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "google/gemini-3.1-pro-preview",
},
"sisyphus-junior": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
@@ -426,20 +426,20 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
"metis": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "google/gemini-3.1-pro-preview",
"variant": "high",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "google/gemini-3.1-pro-preview",
@@ -449,7 +449,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "google/gemini-3.1-pro-preview",
},
"sisyphus-junior": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
@@ -465,7 +465,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"variant": "high",
},
"unspecified-high": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "google/gemini-3-flash-preview",
@@ -929,7 +929,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -938,45 +938,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v",
},
"oracle": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"sisyphus": {
"model": "zai-coding-plan/glm-5",
},
"sisyphus-junior": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
"quick": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
},
"writing": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
}
@@ -987,7 +987,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -996,45 +996,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v",
},
"oracle": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"sisyphus": {
"model": "zai-coding-plan/glm-5",
},
"sisyphus-junior": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
"quick": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "zai-coding-plan/glm-5",
},
"unspecified-low": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "zai-coding-plan/glm-5",
},
"writing": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
},
}
@@ -1273,7 +1273,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"variant": "max",
},
"multimodal-looker": {
"model": "opencode/gpt-5-nano",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "google/gemini-3.1-pro-preview",

View File

@@ -94,9 +94,10 @@ Examples:
Agent resolution order:
1) --agent flag
2) OPENCODE_DEFAULT_AGENT
3) oh-my-opencode.json "default_run_agent"
4) Sisyphus (fallback)
2) OPENCODE_AGENT
3) OPENCODE_DEFAULT_AGENT
4) oh-my-opencode.json "default_run_agent"
5) Sisyphus (fallback)
Available core agents:
Sisyphus, Hephaestus, Prometheus, Atlas

View File

@@ -1,6 +1,5 @@
import { readFileSync, writeFileSync } from "node:fs"
import type { ConfigMergeResult } from "../types"
import { PLUGIN_NAME, LEGACY_PLUGIN_NAME } from "../../shared"
import { getConfigDir } from "./config-context"
import { ensureConfigDirectoryExists } from "./ensure-config-directory-exists"
import { formatErrorWithSuggestion } from "./format-error-with-suggestion"
@@ -8,6 +7,8 @@ import { detectConfigFormat } from "./opencode-config-format"
import { parseOpenCodeConfigFileWithError, type OpenCodeConfig } from "./parse-opencode-config-file"
import { getPluginNameWithVersion } from "./plugin-name-with-version"
const PACKAGE_NAME = "oh-my-opencode"
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
try {
ensureConfigDirectoryExists()
@@ -20,7 +21,7 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
}
const { format, path } = detectConfigFormat()
const pluginEntry = await getPluginNameWithVersion(currentVersion, PLUGIN_NAME)
const pluginEntry = await getPluginNameWithVersion(currentVersion, PACKAGE_NAME)
try {
if (format === "none") {
@@ -40,24 +41,13 @@ export async function addPluginToOpenCodeConfig(currentVersion: string): Promise
const config = parseResult.config
const plugins = config.plugin ?? []
const existingIndex = plugins.findIndex((plugin) => plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`))
// Check for existing plugin (either current or legacy name)
const currentNameIndex = plugins.findIndex(
(plugin) => plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`)
)
const legacyNameIndex = plugins.findIndex(
(plugin) => plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
)
// If either name exists, update to new name
if (currentNameIndex !== -1) {
if (plugins[currentNameIndex] === pluginEntry) {
if (existingIndex !== -1) {
if (plugins[existingIndex] === pluginEntry) {
return { success: true, configPath: path }
}
plugins[currentNameIndex] = pluginEntry
} else if (legacyNameIndex !== -1) {
// Upgrade legacy name to new name
plugins[legacyNameIndex] = pluginEntry
plugins[existingIndex] = pluginEntry
} else {
plugins.push(pluginEntry)
}

View File

@@ -11,8 +11,6 @@ type BunInstallOutputMode = "inherit" | "pipe"
interface RunBunInstallOptions {
outputMode?: BunInstallOutputMode
/** Workspace directory to install to. Defaults to cache dir if not provided. */
workspaceDir?: string
}
interface BunInstallOutput {
@@ -67,7 +65,7 @@ function logCapturedOutputOnFailure(outputMode: BunInstallOutputMode, output: Bu
export async function runBunInstallWithDetails(options?: RunBunInstallOptions): Promise<BunInstallResult> {
const outputMode = options?.outputMode ?? "pipe"
const cacheDir = options?.workspaceDir ?? getOpenCodeCacheDir()
const cacheDir = getOpenCodeCacheDir()
const packageJsonPath = `${cacheDir}/package.json`
if (!existsSync(packageJsonPath)) {

View File

@@ -1,5 +1,5 @@
import { existsSync, readFileSync } from "node:fs"
import { parseJsonc, LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "../../shared"
import { parseJsonc } from "../../shared"
import type { DetectedConfig } from "../types"
import { getOmoConfigPath } from "./config-context"
import { detectConfigFormat } from "./opencode-config-format"
@@ -55,12 +55,8 @@ function detectProvidersFromOmoConfig(): {
}
}
function isOurPlugin(plugin: string): boolean {
return plugin === PLUGIN_NAME || plugin.startsWith(`${PLUGIN_NAME}@`) ||
plugin === LEGACY_PLUGIN_NAME || plugin.startsWith(`${LEGACY_PLUGIN_NAME}@`)
}
export function detectCurrentConfig(): DetectedConfig {
const PACKAGE_NAME = "oh-my-opencode"
const result: DetectedConfig = {
isInstalled: false,
hasClaude: true,
@@ -86,7 +82,7 @@ export function detectCurrentConfig(): DetectedConfig {
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some(isOurPlugin)
result.isInstalled = plugins.some((plugin) => plugin.startsWith(PACKAGE_NAME))
if (!result.isInstalled) {
return result

View File

@@ -52,30 +52,6 @@ describe("detectCurrentConfig - single package detection", () => {
expect(result.isInstalled).toBe(true)
})
it("detects oh-my-openagent as installed (legacy name)", () => {
// given
const config = { plugin: ["oh-my-openagent"] }
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
// when
const result = detectCurrentConfig()
// then
expect(result.isInstalled).toBe(true)
})
it("detects oh-my-openagent with version pin as installed (legacy name)", () => {
// given
const config = { plugin: ["oh-my-openagent@3.11.0"] }
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
// when
const result = detectCurrentConfig()
// then
expect(result.isInstalled).toBe(true)
})
it("returns false when plugin not present", () => {
// given
const config = { plugin: ["some-other-plugin"] }
@@ -88,18 +64,6 @@ describe("detectCurrentConfig - single package detection", () => {
expect(result.isInstalled).toBe(false)
})
it("returns false when plugin not present (even with similar name)", () => {
// given - not exactly oh-my-openagent
const config = { plugin: ["oh-my-openagent-extra"] }
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
// when
const result = detectCurrentConfig()
// then
expect(result.isInstalled).toBe(false)
})
it("detects OpenCode Go from the existing omo config", () => {
// given
writeFileSync(testConfigPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", "utf-8")
@@ -166,38 +130,6 @@ describe("addPluginToOpenCodeConfig - single package writes", () => {
expect(savedConfig.plugin).not.toContain("oh-my-opencode@3.10.0")
})
it("recognizes oh-my-openagent as already installed (legacy name)", async () => {
// given
const config = { plugin: ["oh-my-openagent"] }
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
// when
const result = await addPluginToOpenCodeConfig("3.11.0")
// then
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
// Should upgrade to new name
expect(savedConfig.plugin).toContain("oh-my-opencode")
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
})
it("replaces version-pinned oh-my-openagent@X.Y.Z with new name", async () => {
// given
const config = { plugin: ["oh-my-openagent@3.10.0"] }
writeFileSync(testConfigPath, JSON.stringify(config, null, 2) + "\n", "utf-8")
// when
const result = await addPluginToOpenCodeConfig("3.11.0")
// then
expect(result.success).toBe(true)
const savedConfig = JSON.parse(readFileSync(testConfigPath, "utf-8"))
// Legacy should be replaced with new name
expect(savedConfig.plugin).toContain("oh-my-opencode")
expect(savedConfig.plugin).not.toContain("oh-my-openagent")
})
it("adds new plugin when none exists", async () => {
// given
const config = {}

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "node:fs"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
import { PACKAGE_NAME } from "../constants"
import { getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
export interface PluginInfo {
registered: boolean
@@ -23,33 +24,18 @@ function detectConfigPath(): string | null {
}
function parsePluginVersion(entry: string): string | null {
// Check for current package name
if (entry.startsWith(`${PLUGIN_NAME}@`)) {
const value = entry.slice(PLUGIN_NAME.length + 1)
if (!value || value === "latest") return null
return value
}
// Check for legacy package name
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
const value = entry.slice(LEGACY_PLUGIN_NAME.length + 1)
if (!value || value === "latest") return null
return value
}
return 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) {
// Check for current package name
if (entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)) {
if (entry === PACKAGE_NAME || entry.startsWith(`${PACKAGE_NAME}@`)) {
return { entry, isLocalDev: false }
}
// Check for legacy package name
if (entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
return { entry, isLocalDev: false }
}
// Check for file:// paths that include either name
if (entry.startsWith("file://") && (entry.includes(PLUGIN_NAME) || entry.includes(LEGACY_PLUGIN_NAME))) {
if (entry.startsWith("file://") && entry.includes(PACKAGE_NAME)) {
return { entry, isLocalDev: true }
}
}
@@ -90,7 +76,7 @@ export function getPluginInfo(): PluginInfo {
registered: true,
configPath,
entry: pluginEntry.entry,
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion ?? ""),
isPinned: pinnedVersion !== null && /^\d+\.\d+\.\d+/.test(pinnedVersion),
pinnedVersion,
isLocalDev: pluginEntry.isLocalDev,
}

View File

@@ -19,7 +19,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/gpt-5-nano"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"

View File

@@ -0,0 +1,88 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent } from "./agent-resolver"
const createConfig = (overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig => ({
...overrides,
})
describe("resolveRunAgent", () => {
afterEach(() => {
mock.restore()
})
it("preserves unknown explicit agents while honoring priority over env and config", () => {
//#given
const config = createConfig({ default_run_agent: "prometheus" })
const env = { OPENCODE_DEFAULT_AGENT: "Atlas" }
//#when
const agent = resolveRunAgent({ message: "test", agent: " custom-agent " }, config, env)
//#then
expect(agent).toBe("custom-agent")
})
it("falls back when an env-selected display-name agent is disabled", () => {
//#given
const config = createConfig({ disabled_agents: ["Atlas (Plan Executor)"] })
const env = { OPENCODE_DEFAULT_AGENT: "Atlas (Plan Executor)" }
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
//#when
const agent = resolveRunAgent({ message: "test" }, config, env)
//#then
expect(agent).toBe("Sisyphus (Ultraworker)")
expect(logSpy).toHaveBeenCalledTimes(1)
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("disabled")
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("Sisyphus")
})
it("treats sisyphus_agent.disabled as disabling the config default agent", () => {
//#given
const config = createConfig({
default_run_agent: "sisyphus",
sisyphus_agent: { disabled: true },
})
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
//#when
const agent = resolveRunAgent({ message: "test" }, config, {})
//#then
expect(agent).toBe("Hephaestus (Deep Agent)")
expect(logSpy).toHaveBeenCalledTimes(1)
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("disabled")
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("Hephaestus")
})
it("falls back to the default core agent when a requested core agent is disabled", () => {
//#given
const config = createConfig({ disabled_agents: ["Hephaestus"] })
//#when
const agent = resolveRunAgent({ message: "test", agent: "Hephaestus" }, config, {})
//#then
expect(agent).toBe("Sisyphus (Ultraworker)")
})
it("still returns sisyphus when every core agent is disabled", () => {
//#given
const config = createConfig({
disabled_agents: ["sisyphus", "hephaestus", "prometheus", "atlas"],
})
const logSpy = spyOn(console, "log").mockImplementation(mock(() => undefined))
//#when
const agent = resolveRunAgent({ message: "test", agent: "Atlas" }, config, {})
//#then
expect(agent).toBe("Sisyphus (Ultraworker)")
expect(logSpy).toHaveBeenCalledTimes(1)
expect(String(logSpy.mock.calls[0]?.[0] ?? "")).toContain("no enabled core agent was found")
})
})

View File

@@ -5,6 +5,7 @@ import { getAgentConfigKey, getAgentDisplayName } from "../../shared/agent-displ
const CORE_AGENT_ORDER = ["sisyphus", "hephaestus", "prometheus", "atlas"] as const
const DEFAULT_AGENT = "sisyphus"
const ENV_AGENT_KEYS = ["OPENCODE_AGENT", "OPENCODE_DEFAULT_AGENT"] as const
type EnvVars = Record<string, string | undefined>
type CoreAgentKey = (typeof CORE_AGENT_ORDER)[number]
@@ -54,7 +55,9 @@ export const resolveRunAgent = (
env: EnvVars = process.env
): string => {
const cliAgent = normalizeAgentName(options.agent)
const envAgent = normalizeAgentName(env.OPENCODE_DEFAULT_AGENT)
const envAgent = ENV_AGENT_KEYS
.map((key) => normalizeAgentName(env[key]))
.find((agent) => agent !== undefined)
const configAgent = normalizeAgentName(pluginConfig.default_run_agent)
const resolved =
cliAgent ??

View File

@@ -51,6 +51,10 @@ function getDeltaMessageId(props?: {
return props?.messageID
}
function shouldRender(ctx: RunContext): boolean {
return ctx.renderOutput !== false
}
function renderCompletionMetaLine(state: EventState, messageID: string): void {
if (state.completionMetaPrintedByMessageId[messageID]) return
@@ -95,7 +99,9 @@ export function handleSessionError(ctx: RunContext, payload: EventPayload, state
if (getSessionId(props) === ctx.sessionID) {
state.mainSessionError = true
state.lastError = serializeError(props?.error)
console.error(pc.red(`\n[session.error] ${state.lastError}`))
if (shouldRender(ctx)) {
console.error(pc.red(`\n[session.error] ${state.lastError}`))
}
}
}
@@ -122,6 +128,11 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
}
if (part.type === "reasoning") {
if (!shouldRender(ctx)) {
state.lastReasoningText = part.text ?? ""
state.hasReceivedMeaningfulWork = true
return
}
ensureThinkBlockOpen(state)
const reasoningText = part.text ?? ""
const newText = reasoningText.slice(state.lastReasoningText.length)
@@ -139,15 +150,17 @@ export function handleMessagePartUpdated(ctx: RunContext, payload: EventPayload,
if (part.type === "text" && part.text) {
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
if (newText && shouldRender(ctx)) {
const padded = writePaddedText(newText, state.textAtLineStart)
process.stdout.write(padded.output)
state.textAtLineStart = padded.atLineStart
}
if (newText) {
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
if (part.time?.end) {
if (part.time?.end && shouldRender(ctx)) {
const messageID = part.messageID ?? state.currentMessageId
if (messageID) {
renderCompletionMetaLine(state, messageID)
@@ -180,6 +193,11 @@ export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, s
if (!delta) return
if (partType === "reasoning") {
if (!shouldRender(ctx)) {
state.lastReasoningText += delta
state.hasReceivedMeaningfulWork = true
return
}
ensureThinkBlockOpen(state)
const padded = writePaddedText(delta, state.thinkingAtLineStart)
process.stdout.write(pc.dim(padded.output))
@@ -191,9 +209,11 @@ export function handleMessagePartDelta(ctx: RunContext, payload: EventPayload, s
closeThinkBlockIfNeeded(state)
const padded = writePaddedText(delta, state.textAtLineStart)
process.stdout.write(padded.output)
state.textAtLineStart = padded.atLineStart
if (shouldRender(ctx)) {
const padded = writePaddedText(delta, state.textAtLineStart)
process.stdout.write(padded.output)
state.textAtLineStart = padded.atLineStart
}
state.lastPartText += delta
state.hasReceivedMeaningfulWork = true
}
@@ -209,16 +229,18 @@ function handleToolPart(
if (status === "running") {
if (state.currentTool !== null) return
state.currentTool = toolName
const header = formatToolHeader(toolName, part.state?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
if (shouldRender(_ctx)) {
const header = formatToolHeader(toolName, part.state?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
}
}
if (status === "completed" || status === "error") {
if (state.currentTool === null) return
const output = part.state?.output || ""
if (output.trim()) {
if (output.trim() && shouldRender(_ctx)) {
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
const padded = writePaddedText(output, true)
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))
@@ -271,7 +293,9 @@ export function handleMessageUpdated(ctx: RunContext, payload: EventPayload, sta
state.currentAgent = agent
state.currentModel = model
state.currentVariant = variant
renderAgentHeader(agent, model, variant, state.agentColorsByName)
if (shouldRender(ctx)) {
renderAgentHeader(agent, model, variant, state.agentColorsByName)
}
}
}
@@ -287,11 +311,12 @@ export function handleToolExecute(ctx: RunContext, payload: EventPayload, state:
const toolName = props?.name || "unknown"
state.currentTool = toolName
const header = formatToolHeader(toolName, props?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
if (shouldRender(ctx)) {
const header = formatToolHeader(toolName, props?.input ?? {})
const suffix = header.description ? ` ${pc.dim(header.description)}` : ""
process.stdout.write(`\n ${pc.cyan(header.icon)} ${pc.bold(header.title)}${suffix} \n`)
}
}
export function handleToolResult(ctx: RunContext, payload: EventPayload, state: EventState): void {
@@ -305,7 +330,7 @@ export function handleToolResult(ctx: RunContext, payload: EventPayload, state:
if (state.currentTool === null) return
const output = props?.output || ""
if (output.trim()) {
if (output.trim() && shouldRender(ctx)) {
process.stdout.write(pc.dim(` ${displayChars.treeEnd} output \n`))
const padded = writePaddedText(output, true)
process.stdout.write(pc.dim(padded.output + (padded.atLineStart ? "" : " ")))

View File

@@ -1,5 +1,19 @@
import pc from "picocolors"
import type { RunContext, EventPayload } from "./types"
import type {
EventPayload,
MessagePartDeltaProps,
MessagePartUpdatedProps,
RunContext,
RunEventObserver,
SessionErrorEvent,
SessionIdleEvent,
SessionQuestionEvent,
StreamEvent,
ToolCompletedEvent,
ToolExecuteProps,
ToolResultProps,
ToolStartedEvent,
} from "./types"
import type { EventState } from "./event-state"
import { logEventVerbose } from "./event-formatting"
import {
@@ -14,10 +28,133 @@ import {
handleTuiToast,
} from "./event-handlers"
const QUESTION_TOOL_NAMES = new Set(["question", "ask_user_question", "askuserquestion"])
async function emitObservedEvent(
observer: RunEventObserver | undefined,
event: StreamEvent,
): Promise<void> {
if (!observer) return
await observer.onEvent?.(event)
if (event.type === "session.idle") {
await observer.onIdle?.(event as SessionIdleEvent)
}
if (event.type === "session.question") {
await observer.onQuestion?.(event as SessionQuestionEvent)
}
if (event.type === "session.error") {
await observer.onError?.(event as SessionErrorEvent)
}
}
function getEventSessionId(payload: EventPayload): string | undefined {
const props = payload.properties as Record<string, unknown> | undefined
if (!props) return undefined
if (typeof props.sessionID === "string") return props.sessionID
if (typeof props.sessionId === "string") return props.sessionId
const info = props.info as Record<string, unknown> | undefined
if (typeof info?.sessionID === "string") return info.sessionID
if (typeof info?.sessionId === "string") return info.sessionId
const part = props.part as Record<string, unknown> | undefined
if (typeof part?.sessionID === "string") return part.sessionID
if (typeof part?.sessionId === "string") return part.sessionId
return undefined
}
function getQuestionText(input: unknown): string | undefined {
const args = input as { questions?: Array<{ question?: unknown }> } | undefined
const question = args?.questions?.[0]?.question
return typeof question === "string" && question.length > 0 ? question : undefined
}
function getToolStartFromPayload(
payload: EventPayload,
sessionId: string,
fallbackToolName: string,
): ToolStartedEvent | SessionQuestionEvent | undefined {
if (payload.type === "tool.execute") {
const props = payload.properties as ToolExecuteProps | undefined
const toolName = props?.name ?? fallbackToolName
if (QUESTION_TOOL_NAMES.has(toolName.toLowerCase())) {
return {
type: "session.question",
sessionId,
toolName,
input: props?.input,
question: getQuestionText(props?.input),
}
}
return {
type: "tool.started",
sessionId,
toolName,
input: props?.input,
}
}
if (payload.type === "message.part.updated") {
const props = payload.properties as MessagePartUpdatedProps | undefined
const toolName = props?.part?.tool ?? props?.part?.name ?? fallbackToolName
if (!toolName) return undefined
const input = props?.part?.state?.input
if (QUESTION_TOOL_NAMES.has(toolName.toLowerCase())) {
return {
type: "session.question",
sessionId,
toolName,
input,
question: getQuestionText(input),
}
}
return {
type: "tool.started",
sessionId,
toolName,
input,
}
}
return undefined
}
function getToolCompletedFromPayload(
payload: EventPayload,
sessionId: string,
fallbackToolName: string,
): ToolCompletedEvent | undefined {
if (payload.type === "tool.result") {
const props = payload.properties as ToolResultProps | undefined
return {
type: "tool.completed",
sessionId,
toolName: props?.name ?? fallbackToolName,
output: props?.output,
status: "completed",
}
}
if (payload.type === "message.part.updated") {
const props = payload.properties as MessagePartUpdatedProps | undefined
const status = props?.part?.state?.status
if (status !== "completed" && status !== "error") return undefined
return {
type: "tool.completed",
sessionId,
toolName: props?.part?.tool ?? props?.part?.name ?? fallbackToolName,
output: props?.part?.state?.output,
status,
}
}
return undefined
}
export async function processEvents(
ctx: RunContext,
stream: AsyncIterable<unknown>,
state: EventState
state: EventState,
observer?: RunEventObserver,
): Promise<void> {
for await (const event of stream) {
if (ctx.abortController.signal.aborted) break
@@ -37,6 +174,18 @@ export async function processEvents(
// Update last event timestamp for watchdog detection
state.lastEventTimestamp = Date.now()
const previousIdle = state.mainSessionIdle
const previousError = state.mainSessionError
const previousTool = state.currentTool
const sessionId = getEventSessionId(payload) ?? ctx.sessionID
if (observer?.includeRawEvents) {
await emitObservedEvent(observer, {
type: "raw",
sessionId,
payload,
})
}
handleSessionError(ctx, payload, state)
handleSessionIdle(ctx, payload, state)
@@ -47,8 +196,74 @@ export async function processEvents(
handleToolExecute(ctx, payload, state)
handleToolResult(ctx, payload, state)
handleTuiToast(ctx, payload, state)
if (!previousIdle && state.mainSessionIdle) {
await emitObservedEvent(observer, {
type: "session.idle",
sessionId: ctx.sessionID,
})
}
if (!previousError && state.mainSessionError) {
await emitObservedEvent(observer, {
type: "session.error",
sessionId: ctx.sessionID,
error: state.lastError ?? "Unknown session error",
})
}
if (payload.type === "message.part.delta") {
const props = payload.properties as MessagePartDeltaProps | undefined
if (
sessionId === ctx.sessionID
&& props?.field === "text"
&& typeof props.delta === "string"
&& props.delta.length > 0
) {
await emitObservedEvent(observer, {
type: "message.delta",
sessionId: ctx.sessionID,
messageId: props.messageID,
partId: props.partID,
delta: props.delta,
})
}
}
if (payload.type === "message.part.updated") {
const props = payload.properties as MessagePartUpdatedProps | undefined
if (
sessionId === ctx.sessionID
&& props?.part?.type === "text"
&& typeof props.part.text === "string"
&& props.part.time?.end
) {
await emitObservedEvent(observer, {
type: "message.completed",
sessionId: ctx.sessionID,
messageId: props.part.messageID,
partId: props.part.id,
text: props.part.text,
})
}
}
if (previousTool === null && state.currentTool !== null && sessionId === ctx.sessionID) {
const toolEvent = getToolStartFromPayload(payload, ctx.sessionID, state.currentTool)
if (toolEvent) {
await emitObservedEvent(observer, toolEvent)
}
}
if (previousTool !== null && state.currentTool === null && sessionId === ctx.sessionID) {
const toolEvent = getToolCompletedFromPayload(payload, ctx.sessionID, previousTool)
if (toolEvent) {
await emitObservedEvent(observer, toolEvent)
}
}
} catch (err) {
console.error(pc.red(`[event error] ${err}`))
const error = ctx.logger?.error ?? console.error
error(pc.red(`[event error] ${err}`))
}
}
}

View File

@@ -3,8 +3,16 @@ export { resolveRunAgent } from "./agent-resolver"
export { resolveRunModel } from "./model-resolver"
export { createServerConnection } from "./server-connection"
export { resolveSession } from "./session-resolver"
export { executeRunSession, waitForEventProcessorShutdown } from "./run-engine"
export { createJsonOutputManager } from "./json-output"
export { executeOnCompleteHook } from "./on-complete-hook"
export { createEventState, processEvents, serializeError } from "./events"
export type { EventState } from "./events"
export type { RunOptions, RunContext, RunResult, ServerConnection } from "./types"
export type {
RunContext,
RunEventObserver,
RunOptions,
RunResult,
ServerConnection,
StreamEvent,
} from "./types"

View File

@@ -0,0 +1,142 @@
/// <reference types="bun-types" />
import { describe, expect, it, mock } from "bun:test"
import { executeRunSession } from "./run-engine"
import type { OpencodeClient, StreamEvent } from "./types"
function toAsyncIterable(values: unknown[]): AsyncIterable<unknown> {
return {
async *[Symbol.asyncIterator]() {
for (const value of values) {
yield value
}
},
}
}
describe("executeRunSession", () => {
it("allows SDK sessions to enable questions and emits normalized events", async () => {
const seenEvents: StreamEvent[] = []
const client = {
session: {
create: mock(() => Promise.resolve({ data: { id: "ses_sdk" } })),
promptAsync: mock(() => Promise.resolve({})),
status: mock(() => Promise.resolve({ data: { ses_sdk: { type: "idle" } } })),
todo: mock(() => Promise.resolve({ data: [] })),
children: mock(() => Promise.resolve({ data: [] })),
},
event: {
subscribe: mock(() => Promise.resolve({
stream: toAsyncIterable([
{
type: "message.updated",
properties: {
info: {
id: "msg_1",
sessionID: "ses_sdk",
role: "assistant",
agent: "Prometheus (Plan Builder)",
},
},
},
{
type: "tool.execute",
properties: {
sessionID: "ses_sdk",
name: "question",
input: {
questions: [{ question: "Which agent should run?" }],
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "ses_sdk",
messageID: "msg_1",
partID: "part_1",
field: "text",
delta: "hello",
},
},
{
type: "tool.result",
properties: {
sessionID: "ses_sdk",
name: "question",
output: "waiting",
},
},
{
type: "message.part.updated",
properties: {
part: {
id: "part_1",
sessionID: "ses_sdk",
messageID: "msg_1",
type: "text",
text: "hello",
time: { end: 1 },
},
},
},
{
type: "session.status",
properties: {
sessionID: "ses_sdk",
status: { type: "idle" },
},
},
]),
})),
},
} as unknown as OpencodeClient
const result = await executeRunSession({
client,
directory: "/repo",
message: "hello",
agent: "prometheus",
questionPermission: "allow",
questionToolEnabled: true,
renderOutput: false,
logger: { log: () => {}, error: () => {} },
pluginConfig: {},
pollOptions: {
pollIntervalMs: 1,
minStabilizationMs: 0,
},
eventObserver: {
onEvent: async (event) => {
seenEvents.push(event)
},
},
})
expect(result.exitCode).toBe(0)
expect(result.result.success).toBe(true)
expect(client.session.create).toHaveBeenCalledWith({
body: {
title: "oh-my-opencode run",
permission: [
{ permission: "question", action: "allow", pattern: "*" },
],
},
query: { directory: "/repo" },
})
expect(client.session.promptAsync).toHaveBeenCalledWith({
path: { id: "ses_sdk" },
body: {
agent: "Prometheus (Plan Builder)",
tools: { question: true },
parts: [{ type: "text", text: "hello" }],
},
query: { directory: "/repo" },
})
expect(seenEvents.map((event) => event.type)).toContain("session.started")
expect(seenEvents.map((event) => event.type)).toContain("session.question")
expect(seenEvents.map((event) => event.type)).toContain("message.delta")
expect(seenEvents.map((event) => event.type)).toContain("message.completed")
expect(seenEvents.map((event) => event.type)).toContain("session.completed")
})
})

204
src/cli/run/run-engine.ts Normal file
View File

@@ -0,0 +1,204 @@
import pc from "picocolors"
import type { OhMyOpenCodeConfig } from "../../config"
import { loadPluginConfig } from "../../plugin-config"
import { createEventState, processEvents, serializeError } from "./events"
import { loadAgentProfileColors } from "./agent-profile-colors"
import { pollForCompletion, type PollOptions } from "./poll-for-completion"
import { resolveRunAgent } from "./agent-resolver"
import { resolveRunModel } from "./model-resolver"
import { resolveSession } from "./session-resolver"
import type {
OpencodeClient,
RunContext,
RunEventObserver,
RunLogger,
RunResult,
SessionCompletedEvent,
} from "./types"
const EVENT_PROCESSOR_SHUTDOWN_TIMEOUT_MS = 2_000
export interface ExecuteRunSessionOptions {
client: OpencodeClient
message: string
directory: string
agent?: string
model?: string
sessionId?: string
verbose?: boolean
questionPermission?: "allow" | "deny"
questionToolEnabled?: boolean
pluginConfig?: OhMyOpenCodeConfig
logger?: RunLogger
renderOutput?: boolean
eventObserver?: RunEventObserver
pollOptions?: PollOptions
signal?: AbortSignal
}
export interface ExecuteRunSessionResult {
exitCode: number
result: RunResult
sessionId: string
}
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)),
])
void completed
}
async function emitCompletionEvent(
observer: RunEventObserver | undefined,
result: RunResult,
): Promise<void> {
if (!observer) return
const event: SessionCompletedEvent = {
type: "session.completed",
sessionId: result.sessionId,
result,
}
await observer.onEvent?.(event)
await observer.onComplete?.(event)
}
export async function executeRunSession(
options: ExecuteRunSessionOptions,
): Promise<ExecuteRunSessionResult> {
const {
client,
message,
directory,
agent,
model,
sessionId,
verbose = false,
questionPermission = "deny",
questionToolEnabled = false,
pluginConfig = loadPluginConfig(directory, { command: "run" }),
logger,
renderOutput = true,
eventObserver,
pollOptions,
signal,
} = options
const log = logger?.log ?? console.log
const resolvedAgent = resolveRunAgent({ message, agent }, pluginConfig)
const resolvedModel = resolveRunModel(model)
const abortController = new AbortController()
const startTime = Date.now()
let resolvedSessionId: string | undefined
// Check if signal was already aborted before setting up listener
if (signal?.aborted) {
abortController.abort()
}
const forwardAbort = () => abortController.abort()
signal?.addEventListener("abort", forwardAbort, { once: true })
try {
resolvedSessionId = await resolveSession({
client,
sessionId,
directory,
questionPermission,
logger,
})
if (renderOutput) {
log(pc.dim(`Session: ${resolvedSessionId}`))
if (resolvedModel) {
log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
}
}
await eventObserver?.onEvent?.({
type: "session.started",
sessionId: resolvedSessionId,
agent: resolvedAgent,
resumed: Boolean(sessionId),
...(resolvedModel ? { model: resolvedModel } : {}),
})
const ctx: RunContext = {
client,
sessionID: resolvedSessionId,
directory,
abortController,
verbose,
renderOutput,
logger,
}
const events = await client.event.subscribe({ query: { directory } })
const eventState = createEventState()
if (renderOutput) {
eventState.agentColorsByName = await loadAgentProfileColors(client)
}
const eventProcessor = processEvents(
ctx,
events.stream,
eventState,
eventObserver,
).catch(() => {})
await client.session.promptAsync({
path: { id: resolvedSessionId },
body: {
agent: resolvedAgent,
...(resolvedModel ? { model: resolvedModel } : {}),
tools: {
question: questionToolEnabled,
},
parts: [{ type: "text", text: message }],
},
query: { directory },
})
const exitCode = await pollForCompletion(ctx, eventState, abortController, pollOptions)
abortController.abort()
await waitForEventProcessorShutdown(eventProcessor)
const result: RunResult = {
sessionId: resolvedSessionId,
success: exitCode === 0,
durationMs: Date.now() - startTime,
messageCount: eventState.messageCount,
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
}
if (exitCode === 0) {
await emitCompletionEvent(eventObserver, result)
}
return {
exitCode,
result,
sessionId: resolvedSessionId,
}
} catch (error) {
abortController.abort()
const serialized = serializeError(error)
await eventObserver?.onEvent?.({
type: "session.error",
sessionId: resolvedSessionId ?? sessionId ?? "",
error: serialized,
})
await eventObserver?.onError?.({
type: "session.error",
sessionId: resolvedSessionId ?? sessionId ?? "",
error: serialized,
})
throw error
} finally {
signal?.removeEventListener("abort", forwardAbort)
}
}

View File

@@ -1,6 +1,6 @@
/// <reference types="bun-types" />
import { describe, it, expect, beforeEach, afterEach, vi } from "bun:test"
import { describe, it, expect } from "bun:test"
import type { OhMyOpenCodeConfig } from "../../config"
import { resolveRunAgent, waitForEventProcessorShutdown } from "./runner"
@@ -37,6 +37,38 @@ describe("resolveRunAgent", () => {
expect(agent).toBe("Atlas (Plan Executor)")
})
it("prefers OPENCODE_AGENT over OPENCODE_DEFAULT_AGENT", () => {
// given
const config = createConfig({ default_run_agent: "prometheus" })
const env = {
OPENCODE_AGENT: "oracle",
OPENCODE_DEFAULT_AGENT: "Atlas",
}
// when
const agent = resolveRunAgent({ message: "test" }, config, env)
// then
expect(agent).toBe("oracle")
})
it("supports specialist agents from env and config inputs", () => {
// given
const env = { OPENCODE_AGENT: " explore " }
// when
const envAgent = resolveRunAgent({ message: "test" }, createConfig(), env)
const configAgent = resolveRunAgent(
{ message: "test" },
createConfig({ default_run_agent: "oracle" }),
{}
)
// then
expect(envAgent).toBe("explore")
expect(configAgent).toBe("oracle")
})
it("uses config agent over default", () => {
// given
const config = createConfig({ default_run_agent: "Prometheus" })
@@ -80,9 +112,21 @@ describe("resolveRunAgent", () => {
// then
expect(agent).toBe("Sisyphus (Ultraworker)")
})
it("falls back when requested specialist agent is disabled", () => {
// given
const config = createConfig({ disabled_agents: ["oracle"] })
// when
const agent = resolveRunAgent({ message: "test", agent: "oracle" }, config, {})
// then
expect(agent).toBe("Sisyphus (Ultraworker)")
})
})
describe("waitForEventProcessorShutdown", () => {
it("returns quickly when event processor completes", async () => {
//#given
const eventProcessor = new Promise<void>((resolve) => {
@@ -114,44 +158,3 @@ describe("waitForEventProcessorShutdown", () => {
expect(elapsed).toBeGreaterThanOrEqual(timeoutMs - 10)
})
})
describe("run with invalid model", () => {
it("given invalid --model value, when run, then returns exit code 1 with error message", async () => {
// given
const originalExit = process.exit
const originalError = console.error
const errorMessages: string[] = []
const exitCodes: number[] = []
console.error = (...args: unknown[]) => {
errorMessages.push(args.map(String).join(" "))
}
process.exit = ((code?: number) => {
exitCodes.push(code ?? 0)
throw new Error("exit")
}) as typeof process.exit
try {
// when
// Note: This will actually try to run - but the issue is that resolveRunModel
// is called BEFORE the try block, so it throws an unhandled exception
// We're testing the runner's error handling
const { run } = await import("./runner")
// This will throw because model "invalid" is invalid format
try {
await run({
message: "test",
model: "invalid",
})
} catch {
// Expected to potentially throw due to unhandled model resolution error
}
} finally {
// then - verify error handling
// Currently this will fail because the error is not caught properly
console.error = originalError
process.exit = originalExit
}
})
})

View File

@@ -1,40 +1,23 @@
import pc from "picocolors"
import type { RunOptions, RunContext } from "./types"
import { createEventState, processEvents, serializeError } from "./events"
import { loadPluginConfig } from "../../plugin-config"
import { createServerConnection } from "./server-connection"
import { resolveSession } from "./session-resolver"
import type { RunOptions } from "./types"
import { createJsonOutputManager } from "./json-output"
import { executeOnCompleteHook } from "./on-complete-hook"
import { resolveRunAgent } from "./agent-resolver"
import { resolveRunModel } from "./model-resolver"
import { pollForCompletion } from "./poll-for-completion"
import { loadAgentProfileColors } from "./agent-profile-colors"
import { suppressRunInput } from "./stdin-suppression"
import { createServerConnection } from "./server-connection"
import {
executeRunSession,
waitForEventProcessorShutdown,
} from "./run-engine"
import { createTimestampedStdoutController } from "./timestamp-output"
import { serializeError } from "./events"
import { suppressRunInput } from "./stdin-suppression"
export { resolveRunAgent }
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)),
])
void completed
}
export { resolveRunAgent } from "./agent-resolver"
export { waitForEventProcessorShutdown }
export async function run(options: RunOptions): Promise<number> {
process.env.OPENCODE_CLI_RUN_MODE = "true"
const startTime = Date.now()
const {
message,
directory = process.cwd(),
} = options
@@ -45,26 +28,19 @@ export async function run(options: RunOptions): Promise<number> {
: createTimestampedStdoutController()
timestampOutput?.enable()
const pluginConfig = loadPluginConfig(directory, { command: "run" })
const resolvedAgent = resolveRunAgent(options, pluginConfig)
const abortController = new AbortController()
try {
const resolvedModel = resolveRunModel(options.model)
const { client, cleanup: serverCleanup } = await createServerConnection({
const { client, cleanup } = await createServerConnection({
port: options.port,
attach: options.attach,
signal: abortController.signal,
})
const cleanup = () => {
serverCleanup()
}
const restoreInput = suppressRunInput()
const handleSigint = () => {
console.log(pc.yellow("\nInterrupted. Shutting down..."))
abortController.abort()
restoreInput()
cleanup()
process.exit(130)
@@ -73,81 +49,38 @@ export async function run(options: RunOptions): Promise<number> {
process.on("SIGINT", handleSigint)
try {
const sessionID = await resolveSession({
const { exitCode, result } = await executeRunSession({
client,
message: options.message,
directory,
agent: options.agent,
model: options.model,
sessionId: options.sessionId,
directory,
})
console.log(pc.dim(`Session: ${sessionID}`))
if (resolvedModel) {
console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
}
const ctx: RunContext = {
client,
sessionID,
directory,
abortController,
verbose: options.verbose ?? false,
}
const events = await client.event.subscribe({ query: { directory } })
const eventState = createEventState()
eventState.agentColorsByName = await loadAgentProfileColors(client)
const eventProcessor = processEvents(ctx, events.stream, eventState).catch(
() => {},
)
await client.session.promptAsync({
path: { id: sessionID },
body: {
agent: resolvedAgent,
...(resolvedModel ? { model: resolvedModel } : {}),
tools: {
question: false,
},
parts: [{ type: "text", text: message }],
},
query: { directory },
questionPermission: "deny",
questionToolEnabled: false,
renderOutput: true,
})
const exitCode = await pollForCompletion(ctx, eventState, abortController)
// Abort the event stream to stop the processor
abortController.abort()
await waitForEventProcessorShutdown(eventProcessor)
cleanup()
const durationMs = Date.now() - startTime
if (options.onComplete) {
await executeOnCompleteHook({
command: options.onComplete,
sessionId: sessionID,
sessionId: result.sessionId,
exitCode,
durationMs,
messageCount: eventState.messageCount,
durationMs: result.durationMs,
messageCount: result.messageCount,
})
}
if (jsonManager) {
jsonManager.emitResult({
sessionId: sessionID,
success: exitCode === 0,
durationMs,
messageCount: eventState.messageCount,
summary: eventState.lastPartText.slice(0, 200) || "Run completed",
})
jsonManager.emitResult(result)
}
return exitCode
} catch (err) {
cleanup()
throw err
} finally {
process.removeListener("SIGINT", handleSigint)
restoreInput()
cleanup()
}
} catch (err) {
if (jsonManager) jsonManager.restore()

View File

@@ -1,6 +1,6 @@
import { createOpencode, createOpencodeClient } from "@opencode-ai/sdk"
import pc from "picocolors"
import type { ServerConnection } from "./types"
import type { RunLogger, ServerConnection } from "./types"
import { getAvailableServerPort, isPortAvailable, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
import { withWorkingOpencodePath } from "./opencode-binary-resolver"
@@ -20,13 +20,18 @@ function isPortRangeExhausted(error: unknown): boolean {
return error.message.includes("No available port found in range")
}
async function startServer(options: { signal: AbortSignal, port: number }): Promise<ServerConnection> {
const { signal, port } = options
async function startServer(options: {
signal: AbortSignal
port: number
logger?: RunLogger
}): Promise<ServerConnection> {
const { signal, port, logger } = options
const log = logger?.log?.bind(logger) ?? console.log
const { client, server } = await withWorkingOpencodePath(() =>
createOpencode({ signal, port, hostname: "127.0.0.1" }),
)
console.log(pc.dim("Server listening at"), pc.cyan(server.url))
log(pc.dim("Server listening at"), pc.cyan(server.url))
return { client, cleanup: () => server.close() }
}
@@ -34,11 +39,13 @@ export async function createServerConnection(options: {
port?: number
attach?: string
signal: AbortSignal
logger?: RunLogger
}): Promise<ServerConnection> {
const { port, attach, signal } = options
const { port, attach, signal, logger } = options
const log = logger?.log ?? console.log
if (attach !== undefined) {
console.log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
log(pc.dim("Attaching to existing server at"), pc.cyan(attach))
const client = createOpencodeClient({ baseUrl: attach })
return { client, cleanup: () => {} }
}
@@ -51,9 +58,9 @@ export async function createServerConnection(options: {
const available = await isPortAvailable(port, "127.0.0.1")
if (available) {
console.log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
log(pc.dim("Starting server on port"), pc.cyan(port.toString()))
try {
return await startServer({ signal, port })
return await startServer({ signal, port, logger })
} catch (error) {
if (!isPortStartFailure(error, port)) {
throw error
@@ -64,13 +71,13 @@ export async function createServerConnection(options: {
throw error
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("became occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
return { client, cleanup: () => {} }
}
}
console.log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
log(pc.dim("Port"), pc.cyan(port.toString()), pc.dim("is occupied, attaching to existing server"))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` })
return { client, cleanup: () => {} }
}
@@ -91,26 +98,26 @@ export async function createServerConnection(options: {
throw error
}
console.log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
log(pc.dim("Port range exhausted, attaching to existing server on"), pc.cyan(DEFAULT_SERVER_PORT.toString()))
const client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${DEFAULT_SERVER_PORT}` })
return { client, cleanup: () => {} }
}
if (wasAutoSelected) {
console.log(pc.dim("Auto-selected port"), pc.cyan(selectedPort.toString()))
log(pc.dim("Auto-selected port"), pc.cyan(selectedPort.toString()))
} else {
console.log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
log(pc.dim("Starting server on port"), pc.cyan(selectedPort.toString()))
}
try {
return await startServer({ signal, port: selectedPort })
return await startServer({ signal, port: selectedPort, logger })
} catch (error) {
if (!isPortStartFailure(error, selectedPort)) {
throw error
}
const { port: retryPort } = await getAvailableServerPort(selectedPort + 1, "127.0.0.1")
console.log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
return await startServer({ signal, port: retryPort })
log(pc.dim("Retrying server start on port"), pc.cyan(retryPort.toString()))
return await startServer({ signal, port: retryPort, logger })
}
}

View File

@@ -1,5 +1,5 @@
import pc from "picocolors"
import type { OpencodeClient } from "./types"
import type { OpencodeClient, RunLogger } from "./types"
import { serializeError } from "./events"
const SESSION_CREATE_MAX_RETRIES = 3
@@ -9,8 +9,18 @@ export async function resolveSession(options: {
client: OpencodeClient
sessionId?: string
directory: string
questionPermission?: "allow" | "deny"
logger?: RunLogger
}): Promise<string> {
const { client, sessionId, directory } = options
const {
client,
sessionId,
directory,
questionPermission = "deny",
logger,
} = options
const log = logger?.log ?? console.log
const error = logger?.error ?? console.error
if (sessionId) {
const res = await client.session.get({
@@ -27,23 +37,22 @@ export async function resolveSession(options: {
const res = await client.session.create({
body: {
title: "oh-my-opencode run",
// In CLI run mode there's no TUI to answer questions.
permission: [
{ permission: "question", action: "deny" as const, pattern: "*" },
{ permission: "question", action: questionPermission, pattern: "*" },
],
} as Record<string, unknown>,
query: { directory },
})
if (res.error) {
console.error(
error(
pc.yellow(`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES} failed:`)
)
console.error(pc.dim(` Error: ${serializeError(res.error)}`))
error(pc.dim(` Error: ${serializeError(res.error)}`))
if (attempt < SESSION_CREATE_MAX_RETRIES) {
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
console.log(pc.dim(` Retrying in ${delay}ms...`))
log(pc.dim(` Retrying in ${delay}ms...`))
await new Promise((resolve) => setTimeout(resolve, delay))
}
continue
@@ -53,7 +62,7 @@ export async function resolveSession(options: {
return res.data.id
}
console.error(
error(
pc.yellow(
`Session create attempt ${attempt}/${SESSION_CREATE_MAX_RETRIES}: No session ID returned`
)
@@ -61,7 +70,7 @@ export async function resolveSession(options: {
if (attempt < SESSION_CREATE_MAX_RETRIES) {
const delay = SESSION_CREATE_RETRY_DELAY_MS * attempt
console.log(pc.dim(` Retrying in ${delay}ms...`))
log(pc.dim(` Retrying in ${delay}ms...`))
await new Promise((resolve) => setTimeout(resolve, delay))
}
}

View File

@@ -15,6 +15,11 @@ export interface RunOptions {
sessionId?: string
}
export interface RunLogger {
log?: (...args: unknown[]) => void
error?: (...args: unknown[]) => void
}
export interface ServerConnection {
client: OpencodeClient
cleanup: () => void
@@ -34,6 +39,99 @@ export interface RunContext {
directory: string
abortController: AbortController
verbose?: boolean
renderOutput?: boolean
logger?: RunLogger
}
export interface SessionStartedEvent {
type: "session.started"
sessionId: string
agent: string
resumed: boolean
model?: { providerID: string; modelID: string }
}
export interface MessageDeltaEvent {
type: "message.delta"
sessionId: string
messageId?: string
partId?: string
delta: string
}
export interface MessageCompletedEvent {
type: "message.completed"
sessionId: string
messageId?: string
partId?: string
text: string
}
export interface ToolStartedEvent {
type: "tool.started"
sessionId: string
toolName: string
input?: unknown
}
export interface ToolCompletedEvent {
type: "tool.completed"
sessionId: string
toolName: string
output?: string
status: "completed" | "error"
}
export interface SessionIdleEvent {
type: "session.idle"
sessionId: string
}
export interface SessionQuestionEvent {
type: "session.question"
sessionId: string
toolName: string
input?: unknown
question?: string
}
export interface SessionCompletedEvent {
type: "session.completed"
sessionId: string
result: RunResult
}
export interface SessionErrorEvent {
type: "session.error"
sessionId: string
error: string
}
export interface RawStreamEvent {
type: "raw"
sessionId: string
payload: EventPayload
}
export type StreamEvent =
| SessionStartedEvent
| MessageDeltaEvent
| MessageCompletedEvent
| ToolStartedEvent
| ToolCompletedEvent
| SessionIdleEvent
| SessionQuestionEvent
| SessionCompletedEvent
| SessionErrorEvent
| RawStreamEvent
export interface RunEventObserver {
includeRawEvents?: boolean
onEvent?: (event: StreamEvent) => void | Promise<void>
onIdle?: (event: SessionIdleEvent) => void | Promise<void>
onQuestion?: (event: SessionQuestionEvent) => void | Promise<void>
onComplete?: (event: SessionCompletedEvent) => void | Promise<void>
onError?: (event: SessionErrorEvent) => void | Promise<void>
}
export interface Todo {

View File

@@ -1,59 +0,0 @@
import { describe, expect, test } from "bun:test"
import { ZodError } from "zod/v4"
import { BackgroundTaskConfigSchema } from "./background-task"
describe("BackgroundTaskConfigSchema.circuitBreaker", () => {
describe("#given valid circuit breaker settings", () => {
test("#when parsed #then returns nested config", () => {
const result = BackgroundTaskConfigSchema.parse({
circuitBreaker: {
maxToolCalls: 150,
windowSize: 10,
repetitionThresholdPercent: 70,
},
})
expect(result.circuitBreaker).toEqual({
maxToolCalls: 150,
windowSize: 10,
repetitionThresholdPercent: 70,
})
})
})
describe("#given windowSize below minimum", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({
circuitBreaker: {
windowSize: 4,
},
})
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
describe("#given repetitionThresholdPercent is zero", () => {
test("#when parsed #then throws ZodError", () => {
let thrownError: unknown
try {
BackgroundTaskConfigSchema.parse({
circuitBreaker: {
repetitionThresholdPercent: 0,
},
})
} catch (error) {
thrownError = error
}
expect(thrownError).toBeInstanceOf(ZodError)
})
})
})

View File

@@ -1,12 +1,5 @@
import { z } from "zod"
const CircuitBreakerConfigSchema = z.object({
enabled: z.boolean().optional(),
maxToolCalls: z.number().int().min(10).optional(),
windowSize: z.number().int().min(5).optional(),
repetitionThresholdPercent: z.number().gt(0).max(100).optional(),
})
export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
@@ -18,9 +11,6 @@ export const BackgroundTaskConfigSchema = z.object({
/** Timeout for tasks that never received any progress update, falling back to startedAt (default: 1800000 = 30 minutes, minimum: 60000 = 1 minute) */
messageStalenessTimeoutMs: z.number().min(60000).optional(),
syncPollTimeoutMs: z.number().min(60000).optional(),
/** Maximum tool calls per subagent task before circuit breaker triggers (default: 200, minimum: 10). Prevents runaway loops from burning unlimited tokens. */
maxToolCalls: z.number().int().min(10).optional(),
circuitBreaker: CircuitBreakerConfigSchema.optional(),
})
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>

View File

@@ -51,7 +51,6 @@ export const HookNameSchema = z.enum([
"anthropic-effort",
"hashline-read-enhancer",
"read-image-resizer",
"todo-description-override",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -25,7 +25,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
/** Enable new task system (default: false) */
new_task_system_enabled: z.boolean().optional(),
/** Default agent name for `oh-my-opencode run` (env: OPENCODE_DEFAULT_AGENT) */
/** Default agent name for `oh-my-opencode run` (env fallback: OPENCODE_DEFAULT_AGENT, after OPENCODE_AGENT) */
default_run_agent: z.string().optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(z.string()).optional(),

View File

@@ -2,14 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000
export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
export const MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 1_200_000
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
export const DEFAULT_MAX_TOOL_CALLS = 200
export const DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE = 20
export const DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT = 80
export const DEFAULT_CIRCUIT_BREAKER_ENABLED = true
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,17 +0,0 @@
declare const require: (name: string) => any
const { describe, expect, test } = require("bun:test")
import { DEFAULT_STALE_TIMEOUT_MS } from "./constants"
describe("DEFAULT_STALE_TIMEOUT_MS", () => {
test("uses a 20 minute default", () => {
// #given
const expectedTimeout = 20 * 60 * 1000
// #when
const timeout = DEFAULT_STALE_TIMEOUT_MS
// #then
expect(timeout).toBe(expectedTimeout)
})
})

View File

@@ -1,258 +0,0 @@
import { describe, expect, test } from "bun:test"
import {
createToolCallSignature,
detectRepetitiveToolUse,
recordToolCall,
resolveCircuitBreakerSettings,
} from "./loop-detector"
function buildWindow(
toolNames: string[],
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
) {
const settings = resolveCircuitBreakerSettings(override)
return toolNames.reduce(
(window, toolName) => recordToolCall(window, toolName, settings),
undefined as ReturnType<typeof recordToolCall> | undefined
)
}
function buildWindowWithInputs(
calls: Array<{ tool: string; input?: Record<string, unknown> }>,
override?: Parameters<typeof resolveCircuitBreakerSettings>[0]
) {
const settings = resolveCircuitBreakerSettings(override)
return calls.reduce(
(window, { tool, input }) => recordToolCall(window, tool, settings, input),
undefined as ReturnType<typeof recordToolCall> | undefined
)
}
describe("loop-detector", () => {
describe("resolveCircuitBreakerSettings", () => {
describe("#given nested circuit breaker config", () => {
test("#when resolved #then nested values override defaults", () => {
const result = resolveCircuitBreakerSettings({
maxToolCalls: 200,
circuitBreaker: {
maxToolCalls: 120,
windowSize: 10,
repetitionThresholdPercent: 70,
},
})
expect(result).toEqual({
enabled: true,
maxToolCalls: 120,
windowSize: 10,
repetitionThresholdPercent: 70,
})
})
})
describe("#given no enabled config", () => {
test("#when resolved #then enabled defaults to true", () => {
const result = resolveCircuitBreakerSettings({
circuitBreaker: {
maxToolCalls: 100,
windowSize: 5,
repetitionThresholdPercent: 60,
},
})
expect(result.enabled).toBe(true)
})
})
describe("#given enabled is false in config", () => {
test("#when resolved #then enabled is false", () => {
const result = resolveCircuitBreakerSettings({
circuitBreaker: {
enabled: false,
maxToolCalls: 100,
windowSize: 5,
repetitionThresholdPercent: 60,
},
})
expect(result.enabled).toBe(false)
})
})
describe("#given enabled is true in config", () => {
test("#when resolved #then enabled is true", () => {
const result = resolveCircuitBreakerSettings({
circuitBreaker: {
enabled: true,
maxToolCalls: 100,
windowSize: 5,
repetitionThresholdPercent: 60,
},
})
expect(result.enabled).toBe(true)
})
})
})
describe("createToolCallSignature", () => {
test("#given tool with input #when signature created #then includes tool and sorted input", () => {
const result = createToolCallSignature("read", { filePath: "/a.ts" })
expect(result).toBe('read::{"filePath":"/a.ts"}')
})
test("#given tool with undefined input #when signature created #then returns bare tool name", () => {
const result = createToolCallSignature("read", undefined)
expect(result).toBe("read")
})
test("#given tool with null input #when signature created #then returns bare tool name", () => {
const result = createToolCallSignature("read", null)
expect(result).toBe("read")
})
test("#given tool with empty object input #when signature created #then returns bare tool name", () => {
const result = createToolCallSignature("read", {})
expect(result).toBe("read")
})
test("#given same input different key order #when signatures compared #then they are equal", () => {
const first = createToolCallSignature("read", { filePath: "/a.ts", offset: 0 })
const second = createToolCallSignature("read", { offset: 0, filePath: "/a.ts" })
expect(first).toBe(second)
})
})
describe("detectRepetitiveToolUse", () => {
describe("#given recent tools are diverse", () => {
test("#when evaluated #then it does not trigger", () => {
const window = buildWindow([
"read",
"grep",
"edit",
"bash",
"read",
"glob",
"lsp_diagnostics",
"read",
"grep",
"edit",
])
const result = detectRepetitiveToolUse(window)
expect(result.triggered).toBe(false)
})
})
describe("#given the same tool dominates the recent window", () => {
test("#when evaluated #then it triggers", () => {
const window = buildWindow([
"read",
"read",
"read",
"edit",
"read",
"read",
"read",
"read",
"grep",
"read",
], {
circuitBreaker: {
windowSize: 10,
repetitionThresholdPercent: 80,
},
})
const result = detectRepetitiveToolUse(window)
expect(result).toEqual({
triggered: true,
toolName: "read",
repeatedCount: 8,
sampleSize: 10,
thresholdPercent: 80,
})
})
})
describe("#given the window is not full yet", () => {
test("#when the current sample crosses the threshold #then it still triggers", () => {
const window = buildWindow(["read", "read", "edit", "read", "read", "read", "read", "read"], {
circuitBreaker: {
windowSize: 10,
repetitionThresholdPercent: 80,
},
})
const result = detectRepetitiveToolUse(window)
expect(result).toEqual({
triggered: true,
toolName: "read",
repeatedCount: 7,
sampleSize: 8,
thresholdPercent: 80,
})
})
})
describe("#given same tool with different file inputs", () => {
test("#when evaluated #then it does not trigger", () => {
const calls = Array.from({ length: 20 }, (_, i) => ({
tool: "read",
input: { filePath: `/src/file-${i}.ts` },
}))
const window = buildWindowWithInputs(calls, {
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
})
const result = detectRepetitiveToolUse(window)
expect(result.triggered).toBe(false)
})
})
describe("#given same tool with identical file inputs", () => {
test("#when evaluated #then it triggers with bare tool name", () => {
const calls = [
...Array.from({ length: 16 }, () => ({ tool: "read", input: { filePath: "/src/same.ts" } })),
{ tool: "grep", input: { pattern: "foo" } },
{ tool: "edit", input: { filePath: "/src/other.ts" } },
{ tool: "bash", input: { command: "ls" } },
{ tool: "glob", input: { pattern: "**/*.ts" } },
]
const window = buildWindowWithInputs(calls, {
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
})
const result = detectRepetitiveToolUse(window)
expect(result.triggered).toBe(true)
expect(result.toolName).toBe("read")
expect(result.repeatedCount).toBe(16)
})
})
describe("#given tool calls with no input", () => {
test("#when the same tool dominates #then falls back to name-only detection", () => {
const calls = [
...Array.from({ length: 16 }, () => ({ tool: "read" })),
{ tool: "grep" },
{ tool: "edit" },
{ tool: "bash" },
{ tool: "glob" },
]
const window = buildWindowWithInputs(calls, {
circuitBreaker: { windowSize: 20, repetitionThresholdPercent: 80 },
})
const result = detectRepetitiveToolUse(window)
expect(result.triggered).toBe(true)
expect(result.toolName).toBe("read")
})
})
})
})

View File

@@ -1,127 +0,0 @@
import type { BackgroundTaskConfig } from "../../config/schema"
import {
DEFAULT_CIRCUIT_BREAKER_ENABLED,
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
DEFAULT_MAX_TOOL_CALLS,
} from "./constants"
import type { ToolCallWindow } from "./types"
export interface CircuitBreakerSettings {
enabled: boolean
maxToolCalls: number
windowSize: number
repetitionThresholdPercent: number
}
export interface ToolLoopDetectionResult {
triggered: boolean
toolName?: string
repeatedCount?: number
sampleSize?: number
thresholdPercent?: number
}
export function resolveCircuitBreakerSettings(
config?: BackgroundTaskConfig
): CircuitBreakerSettings {
return {
enabled: config?.circuitBreaker?.enabled ?? DEFAULT_CIRCUIT_BREAKER_ENABLED,
maxToolCalls:
config?.circuitBreaker?.maxToolCalls ?? config?.maxToolCalls ?? DEFAULT_MAX_TOOL_CALLS,
windowSize: config?.circuitBreaker?.windowSize ?? DEFAULT_CIRCUIT_BREAKER_WINDOW_SIZE,
repetitionThresholdPercent:
config?.circuitBreaker?.repetitionThresholdPercent ??
DEFAULT_CIRCUIT_BREAKER_REPETITION_THRESHOLD_PERCENT,
}
}
export function recordToolCall(
window: ToolCallWindow | undefined,
toolName: string,
settings: CircuitBreakerSettings,
toolInput?: Record<string, unknown> | null
): ToolCallWindow {
const previous = window?.toolSignatures ?? []
const signature = createToolCallSignature(toolName, toolInput)
const toolSignatures = [...previous, signature].slice(-settings.windowSize)
return {
toolSignatures,
windowSize: settings.windowSize,
thresholdPercent: settings.repetitionThresholdPercent,
}
}
function sortObject(obj: unknown): unknown {
if (obj === null || obj === undefined) return obj
if (typeof obj !== "object") return obj
if (Array.isArray(obj)) return obj.map(sortObject)
const sorted: Record<string, unknown> = {}
const keys = Object.keys(obj as Record<string, unknown>).sort()
for (const key of keys) {
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
}
return sorted
}
export function createToolCallSignature(
toolName: string,
toolInput?: Record<string, unknown> | null
): string {
if (toolInput === undefined || toolInput === null) {
return toolName
}
if (Object.keys(toolInput).length === 0) {
return toolName
}
return `${toolName}::${JSON.stringify(sortObject(toolInput))}`
}
export function detectRepetitiveToolUse(
window: ToolCallWindow | undefined
): ToolLoopDetectionResult {
if (!window || window.toolSignatures.length === 0) {
return { triggered: false }
}
const counts = new Map<string, number>()
for (const signature of window.toolSignatures) {
counts.set(signature, (counts.get(signature) ?? 0) + 1)
}
let repeatedTool: string | undefined
let repeatedCount = 0
for (const [toolName, count] of counts.entries()) {
if (count > repeatedCount) {
repeatedTool = toolName
repeatedCount = count
}
}
const sampleSize = window.toolSignatures.length
const minimumSampleSize = Math.min(
window.windowSize,
Math.ceil((window.windowSize * window.thresholdPercent) / 100)
)
if (sampleSize < minimumSampleSize) {
return { triggered: false }
}
const thresholdCount = Math.ceil((sampleSize * window.thresholdPercent) / 100)
if (!repeatedTool || repeatedCount < thresholdCount) {
return { triggered: false }
}
return {
triggered: true,
toolName: repeatedTool.split("::")[0],
repeatedCount,
sampleSize,
thresholdPercent: window.thresholdPercent,
}
}

View File

@@ -1,416 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { tmpdir } from "node:os"
import type { BackgroundTaskConfig } from "../../config/schema"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
function createManager(config?: BackgroundTaskConfig): BackgroundManager {
const client = {
session: {
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, config)
const testManager = manager as unknown as {
enqueueNotificationForParent: (sessionID: string, fn: () => Promise<void>) => Promise<void>
notifyParentSession: (task: BackgroundTask) => Promise<void>
tasks: Map<string, BackgroundTask>
}
testManager.enqueueNotificationForParent = async (_sessionID, fn) => {
await fn()
}
testManager.notifyParentSession = async () => {}
return manager
}
function getTaskMap(manager: BackgroundManager): Map<string, BackgroundTask> {
return (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
}
async function flushAsyncWork() {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe("BackgroundManager circuit breaker", () => {
describe("#given the same tool dominates the recent window", () => {
test("#when tool events arrive #then the task is cancelled early", async () => {
const manager = createManager({
circuitBreaker: {
windowSize: 20,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-loop-1",
sessionID: "session-loop-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Looping task",
prompt: "loop",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (const toolName of [
"read",
"read",
"grep",
"read",
"edit",
"read",
"read",
"bash",
"read",
"read",
"read",
"glob",
"read",
"read",
"read",
"read",
"read",
"read",
"read",
"read",
]) {
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
})
}
await flushAsyncWork()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("repeatedly called read 16/20 times")
})
})
describe("#given recent tool calls are diverse", () => {
test("#when the window fills #then the task keeps running", async () => {
const manager = createManager({
circuitBreaker: {
windowSize: 10,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-diverse-1",
sessionID: "session-diverse-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Healthy task",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (const toolName of [
"read",
"grep",
"edit",
"bash",
"glob",
"read",
"lsp_diagnostics",
"grep",
"edit",
"read",
]) {
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
})
}
await flushAsyncWork()
expect(task.status).toBe("running")
expect(task.progress?.toolCalls).toBe(10)
})
})
describe("#given the absolute cap is configured lower than the repetition detector needs", () => {
test("#when the raw tool-call cap is reached #then the backstop still cancels the task", async () => {
const manager = createManager({
maxToolCalls: 3,
circuitBreaker: {
windowSize: 10,
repetitionThresholdPercent: 95,
},
})
const task: BackgroundTask = {
id: "task-cap-1",
sessionID: "session-cap-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Backstop task",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (const toolName of ["read", "grep", "edit"]) {
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
})
}
await flushAsyncWork()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("maximum tool call limit (3)")
})
})
describe("#given the same running tool part emits multiple updates", () => {
test("#when duplicate running updates arrive #then it only counts the tool once", async () => {
const manager = createManager({
maxToolCalls: 2,
circuitBreaker: {
windowSize: 5,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-dedupe-1",
sessionID: "session-dedupe-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Dedupe task",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (let index = 0; index < 3; index += 1) {
manager.handleEvent({
type: "message.part.updated",
properties: {
part: {
id: "tool-1",
sessionID: task.sessionID,
type: "tool",
tool: "bash",
state: { status: "running" },
},
},
})
}
await flushAsyncWork()
expect(task.status).toBe("running")
expect(task.progress?.toolCalls).toBe(1)
expect(task.progress?.countedToolPartIDs).toEqual(["tool-1"])
})
})
describe("#given same tool reading different files", () => {
test("#when tool events arrive with state.input #then task keeps running", async () => {
const manager = createManager({
circuitBreaker: {
windowSize: 20,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-diff-files-1",
sessionID: "session-diff-files-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reading different files",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (let i = 0; i < 20; i++) {
manager.handleEvent({
type: "message.part.updated",
properties: {
part: {
sessionID: task.sessionID,
type: "tool",
tool: "read",
state: { status: "running", input: { filePath: `/src/file-${i}.ts` } },
},
},
})
}
await flushAsyncWork()
expect(task.status).toBe("running")
expect(task.progress?.toolCalls).toBe(20)
})
})
describe("#given same tool reading same file repeatedly", () => {
test("#when tool events arrive with state.input #then task is cancelled with bare tool name in error", async () => {
const manager = createManager({
circuitBreaker: {
windowSize: 20,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-same-file-1",
sessionID: "session-same-file-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Reading same file repeatedly",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (let i = 0; i < 20; i++) {
manager.handleEvent({
type: "message.part.updated",
properties: {
part: {
sessionID: task.sessionID,
type: "tool",
tool: "read",
state: { status: "running", input: { filePath: "/src/same.ts" } },
},
},
})
}
await flushAsyncWork()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("repeatedly called read")
expect(task.error).not.toContain("::")
})
})
describe("#given circuit breaker enabled is false", () => {
test("#when repetitive tools arrive #then task keeps running", async () => {
const manager = createManager({
circuitBreaker: {
enabled: false,
windowSize: 20,
repetitionThresholdPercent: 80,
},
})
const task: BackgroundTask = {
id: "task-disabled-1",
sessionID: "session-disabled-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Disabled circuit breaker task",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (let i = 0; i < 20; i++) {
manager.handleEvent({
type: "message.part.updated",
properties: {
sessionID: task.sessionID,
type: "tool",
tool: "read",
},
})
}
await flushAsyncWork()
expect(task.status).toBe("running")
})
})
describe("#given circuit breaker enabled is false but absolute cap is low", () => {
test("#when max tool calls exceeded #then task is still cancelled by absolute cap", async () => {
const manager = createManager({
maxToolCalls: 3,
circuitBreaker: {
enabled: false,
windowSize: 10,
repetitionThresholdPercent: 95,
},
})
const task: BackgroundTask = {
id: "task-cap-disabled-1",
sessionID: "session-cap-disabled-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Backstop task with disabled circuit breaker",
prompt: "work",
agent: "explore",
status: "running",
startedAt: new Date(Date.now() - 60_000),
progress: {
toolCalls: 0,
lastUpdate: new Date(Date.now() - 60_000),
},
}
getTaskMap(manager).set(task.id, task)
for (const toolName of ["read", "grep", "edit"]) {
manager.handleEvent({
type: "message.part.updated",
properties: { sessionID: task.sessionID, type: "tool", tool: toolName },
})
}
await flushAsyncWork()
expect(task.status).toBe("cancelled")
expect(task.error).toContain("maximum tool call limit (3)")
})
})
})

View File

@@ -3027,10 +3027,10 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(Date.now() - 25 * 60 * 1000),
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 21 * 60 * 1000),
lastUpdate: new Date(Date.now() - 200_000),
},
}

View File

@@ -27,7 +27,6 @@ import {
import {
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
TASK_TTL_MS,
} from "./constants"
import { subagentSessions } from "../claude-code-session-state"
@@ -52,11 +51,6 @@ import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import {
detectRepetitiveToolUse,
recordToolCall,
resolveCircuitBreakerSettings,
} from "./loop-detector"
import {
createSubagentDepthLimitError,
createSubagentDescendantLimitError,
@@ -70,11 +64,9 @@ type OpencodeClient = PluginInput["client"]
interface MessagePartInfo {
id?: string
sessionID?: string
type?: string
tool?: string
state?: { status?: string; input?: Record<string, unknown> }
}
interface EventProperties {
@@ -88,19 +80,6 @@ interface Event {
properties?: EventProperties
}
function resolveMessagePartInfo(properties: EventProperties | undefined): MessagePartInfo | undefined {
if (!properties || typeof properties !== "object") {
return undefined
}
const nestedPart = properties.part
if (nestedPart && typeof nestedPart === "object") {
return nestedPart as MessagePartInfo
}
return properties as MessagePartInfo
}
interface Todo {
content: string
status: string
@@ -121,8 +100,6 @@ export interface SubagentSessionCreatedEvent {
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
const MAX_TASK_REMOVAL_RESCHEDULES = 6
export class BackgroundManager {
@@ -743,8 +720,6 @@ export class BackgroundManager {
existingTask.progress = {
toolCalls: existingTask.progress?.toolCalls ?? 0,
toolCallWindow: existingTask.progress?.toolCallWindow,
countedToolPartIDs: existingTask.progress?.countedToolPartIDs,
lastUpdate: new Date(),
}
@@ -877,7 +852,8 @@ export class BackgroundManager {
}
if (event.type === "message.part.updated" || event.type === "message.part.delta") {
const partInfo = resolveMessagePartInfo(props)
if (!props || typeof props !== "object" || !("sessionID" in props)) return
const partInfo = props as unknown as MessagePartInfo
const sessionID = partInfo?.sessionID
if (!sessionID) return
@@ -900,66 +876,8 @@ export class BackgroundManager {
task.progress.lastUpdate = new Date()
if (partInfo?.type === "tool" || partInfo?.tool) {
const countedToolPartIDs = task.progress.countedToolPartIDs ?? []
const shouldCountToolCall =
!partInfo.id ||
partInfo.state?.status !== "running" ||
!countedToolPartIDs.includes(partInfo.id)
if (!shouldCountToolCall) {
return
}
if (partInfo.id && partInfo.state?.status === "running") {
task.progress.countedToolPartIDs = [...countedToolPartIDs, partInfo.id]
}
task.progress.toolCalls += 1
task.progress.lastTool = partInfo.tool
const circuitBreaker = resolveCircuitBreakerSettings(this.config)
if (partInfo.tool) {
task.progress.toolCallWindow = recordToolCall(
task.progress.toolCallWindow,
partInfo.tool,
circuitBreaker,
partInfo.state?.input
)
if (circuitBreaker.enabled) {
const loopDetection = detectRepetitiveToolUse(task.progress.toolCallWindow)
if (loopDetection.triggered) {
log("[background-agent] Circuit breaker: repetitive tool usage detected", {
taskId: task.id,
agent: task.agent,
sessionID,
toolName: loopDetection.toolName,
repeatedCount: loopDetection.repeatedCount,
sampleSize: loopDetection.sampleSize,
thresholdPercent: loopDetection.thresholdPercent,
})
void this.cancelTask(task.id, {
source: "circuit-breaker",
reason: `Subagent repeatedly called ${loopDetection.toolName} ${loopDetection.repeatedCount}/${loopDetection.sampleSize} times in the recent tool-call window (${loopDetection.thresholdPercent}% threshold). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
})
return
}
}
}
const maxToolCalls = circuitBreaker.maxToolCalls
if (task.progress.toolCalls >= maxToolCalls) {
log("[background-agent] Circuit breaker: tool call limit reached", {
taskId: task.id,
toolCalls: task.progress.toolCalls,
maxToolCalls,
agent: task.agent,
sessionID,
})
void this.cancelTask(task.id, {
source: "circuit-breaker",
reason: `Subagent exceeded maximum tool call limit (${maxToolCalls}). This usually indicates an infinite loop. The task was automatically cancelled to prevent excessive token usage.`,
})
}
}
}
@@ -1270,7 +1188,7 @@ export class BackgroundManager {
this.completedTaskSummaries.delete(parentSessionID)
}
private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
private scheduleTaskRemoval(taskId: string): void {
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
@@ -1280,29 +1198,17 @@ export class BackgroundManager {
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
const task = this.tasks.get(taskId)
if (!task) return
if (task.parentSessionID) {
const siblings = this.getTasksByParentSession(task.parentSessionID)
const runningOrPendingSiblings = siblings.filter(
sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
)
const completedAtTimestamp = task.completedAt?.getTime()
const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
return
if (task) {
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
log("[background-agent] Removed completed task from memory:", taskId)
this.clearTaskHistoryWhenParentTasksGone(task?.parentSessionID)
}
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
log("[background-agent] Removed completed task from memory:", taskId)
}, TASK_CLEANUP_DELAY_MS)
this.completionTimers.set(taskId, timer)

View File

@@ -1,5 +1,6 @@
declare const require: (name: string) => any
const { describe, test, expect, afterEach } = require("bun:test")
import { tmpdir } from "node:os"
import { afterEach, describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { BackgroundManager } from "./manager"
@@ -156,19 +157,17 @@ function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnTyp
}
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
describe("#given 3 tasks for same parent and task A completed first", () => {
test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => {
describe("#given 2 tasks for same parent and task A completed", () => {
test("#when task B is still running #then task A is cleaned up from this.tasks after delay even though task B is not done", async () => {
// given
const { manager } = createManager(false)
managerUnderTest = manager
fakeTimers = installFakeTimers()
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date() })
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
const taskC = createTask({ id: "task-c", parentSessionID: "parent-1", description: "task C", status: "pending" })
getTasks(manager).set(taskA.id, taskA)
getTasks(manager).set(taskB.id, taskB)
getTasks(manager).set(taskC.id, taskC)
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id, taskC.id]))
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
// when
await notifyParentSessionForTest(manager, taskA)
@@ -178,23 +177,8 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// then
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
expect(getTasks(manager).has(taskA.id)).toBe(true)
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
expect(getTasks(manager).get(taskC.id)).toBe(taskC)
// when
taskB.status = "completed"
taskB.completedAt = new Date()
taskC.status = "completed"
taskC.completedAt = new Date()
await notifyParentSessionForTest(manager, taskB)
await notifyParentSessionForTest(manager, taskC)
const rescheduledTaskATimer = getRequiredTimer(manager, taskA.id)
expect(fakeTimers.getDelay(rescheduledTaskATimer)).toBe(TASK_CLEANUP_DELAY_MS)
fakeTimers.run(rescheduledTaskATimer)
// then
expect(getTasks(manager).has(taskA.id)).toBe(false)
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
})
})

View File

@@ -9,11 +9,12 @@ import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
MIN_RUNTIME_BEFORE_STALE_MS,
TERMINAL_TASK_TTL_MS,
TASK_TTL_MS,
} from "./constants"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",

View File

@@ -9,17 +9,9 @@ export type BackgroundTaskStatus =
| "cancelled"
| "interrupt"
export interface ToolCallWindow {
toolSignatures: string[]
windowSize: number
thresholdPercent: number
}
export interface TaskProgress {
toolCalls: number
lastTool?: string
toolCallWindow?: ToolCallWindow
countedToolPartIDs?: string[]
lastUpdate: Date
lastMessage?: string
lastMessageAt?: Date

View File

@@ -59,13 +59,10 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
if (!Array.isArray(state.session_ids)) {
state.session_ids = []
}
const originalSessionIds = [...state.session_ids]
state.session_ids.push(sessionId)
if (writeBoulderState(directory, state)) {
return state
}
state.session_ids = originalSessionIds
return null
}
return state

View File

@@ -7,7 +7,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
- \`--worktree <path>\` (optional): absolute path to an existing git worktree to work in
- If specified and valid: hook pre-sets worktree_path in boulder.json
- If specified but invalid: you must run \`git worktree add <path> <branch>\` first
- If omitted: work directly in the current project directory (no worktree)
- If omitted: you MUST choose or create a worktree (see Worktree Setup below)
## WHAT TO DO
@@ -24,7 +24,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
- If ONE plan: auto-select it
- If MULTIPLE plans: show list with timestamps, ask user to select
4. **Worktree Setup** (ONLY when \`--worktree\` was explicitly specified and \`worktree_path\` not already set in boulder.json):
4. **Worktree Setup** (when \`worktree_path\` not already set in boulder.json):
1. \`git worktree list --porcelain\` — see available worktrees
2. Create: \`git worktree add <absolute-path> <branch-or-HEAD>\`
3. Update boulder.json to add \`"worktree_path": "<absolute-path>"\`
@@ -86,38 +86,6 @@ Reading plan and beginning execution...
- The session_id is injected by the hook - use it directly
- Always update boulder.json BEFORE starting work
- If worktree_path is set in boulder.json, all work happens inside that worktree directory
- Always set worktree_path in boulder.json before executing any tasks
- Read the FULL plan file before delegating any tasks
- Follow atlas delegation protocols (7-section format)
## TASK BREAKDOWN (MANDATORY)
After reading the plan file, you MUST decompose every plan task into granular, implementation-level sub-steps and register ALL of them as task/todo items BEFORE starting any work.
**How to break down**:
- Each plan checkbox item (e.g., \`- [ ] Add user authentication\`) must be split into concrete, actionable sub-tasks
- Sub-tasks should be specific enough that each one touches a clear set of files/functions
- Include: file to modify, what to change, expected behavior, and how to verify
- Do NOT leave any task vague — "implement feature X" is NOT acceptable; "add validateToken() to src/auth/middleware.ts that checks JWT expiry and returns 401" IS acceptable
**Example breakdown**:
Plan task: \`- [ ] Add rate limiting to API\`
→ Todo items:
1. Create \`src/middleware/rate-limiter.ts\` with sliding window algorithm (max 100 req/min per IP)
2. Add RateLimiter middleware to \`src/app.ts\` router chain, before auth middleware
3. Add rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining) to response in \`rate-limiter.ts\`
4. Add test: verify 429 response after exceeding limit in \`src/middleware/rate-limiter.test.ts\`
5. Add test: verify headers are present on normal responses
Register these as task/todo items so progress is tracked and visible throughout the session.
## WORKTREE COMPLETION
When working in a worktree (\`worktree_path\` is set in boulder.json) and ALL plan tasks are complete:
1. Commit all remaining changes in the worktree
2. Switch to the main working directory (the original repo, NOT the worktree)
3. Merge the worktree branch into the current branch: \`git merge <worktree-branch>\`
4. If merge succeeds, clean up: \`git worktree remove <worktree-path>\`
5. Remove the boulder.json state
This is the DEFAULT behavior when \`--worktree\` was used. Skip merge only if the user explicitly instructs otherwise (e.g., asks to create a PR instead).`
- Follow atlas delegation protocols (7-section format)`

View File

@@ -75,10 +75,6 @@ describe("mapClaudeModelToOpenCode", () => {
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
it("#when called with anthropic/claude-3.5-sonnet #then normalizes dots before splitting into object format", () => {
expect(mapClaudeModelToOpenCode("anthropic/claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
})
it("#when called with openai/gpt-5.2 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("openai/gpt-5.2")).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
})

View File

@@ -20,16 +20,7 @@ function mapClaudeModelString(model: string | undefined): string | undefined {
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
if (aliasResult) return aliasResult
if (trimmed.includes("/")) {
const [providerID, ...modelParts] = trimmed.split("/")
const modelID = modelParts.join("/")
if (providerID.length === 0 || modelID.length === 0) return trimmed
return modelID.startsWith("claude-")
? `${providerID}/${normalizeModelID(modelID)}`
: trimmed
}
if (trimmed.includes("/")) return trimmed
const normalized = normalizeModelID(trimmed)

View File

@@ -153,25 +153,3 @@ describe("#given git_env_prefix with commit footer", () => {
})
})
})
describe("#given idempotency of prefixGitCommandsInBashCodeBlocks", () => {
describe("#when git_env_prefix is provided and template already has prefixed commands in env prefix section", () => {
it("#then does NOT double-prefix the already-prefixed commands", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git status")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git add")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git commit")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git push")
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("GIT_MASTER=1 git add")
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("GIT_MASTER=1 git push")
})
})
})

View File

@@ -72,16 +72,8 @@ function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): st
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
return codeBlock
.split("\n")
.map((line) => {
if (line.includes(prefix)) {
return line
}
return line
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
})
.join("\n")
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
}
function buildCommitFooterInjection(

View File

@@ -199,236 +199,3 @@ describe("EXCLUDED_ENV_PATTERNS", () => {
}
})
})
describe("secret env var filtering", () => {
it("filters out ANTHROPIC_API_KEY", () => {
// given
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-secret"
process.env.PATH = "/usr/bin"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.ANTHROPIC_API_KEY).toBeUndefined()
expect(cleanEnv.PATH).toBe("/usr/bin")
})
it("filters out AWS_SECRET_ACCESS_KEY", () => {
// given
process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
process.env.HOME = "/home/user"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined()
expect(cleanEnv.AWS_ACCESS_KEY_ID).toBeUndefined()
expect(cleanEnv.HOME).toBe("/home/user")
})
it("filters out GITHUB_TOKEN", () => {
// given
process.env.GITHUB_TOKEN = "ghp_secrettoken123456789"
process.env.GITHUB_API_TOKEN = "another_secret_token"
process.env.SHELL = "/bin/bash"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.GITHUB_TOKEN).toBeUndefined()
expect(cleanEnv.GITHUB_API_TOKEN).toBeUndefined()
expect(cleanEnv.SHELL).toBe("/bin/bash")
})
it("filters out OPENAI_API_KEY", () => {
// given
process.env.OPENAI_API_KEY = "sk-secret123456789"
process.env.LANG = "en_US.UTF-8"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.OPENAI_API_KEY).toBeUndefined()
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
})
it("filters out DATABASE_URL with credentials", () => {
// given
process.env.DATABASE_URL = "postgresql://user:password@localhost:5432/db"
process.env.DB_PASSWORD = "supersecretpassword"
process.env.TERM = "xterm-256color"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.DATABASE_URL).toBeUndefined()
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
expect(cleanEnv.TERM).toBe("xterm-256color")
})
})
describe("suffix-based secret filtering", () => {
it("filters variables ending with _KEY", () => {
// given
process.env.MY_API_KEY = "secret-value"
process.env.SOME_KEY = "another-secret"
process.env.TMPDIR = "/tmp"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.MY_API_KEY).toBeUndefined()
expect(cleanEnv.SOME_KEY).toBeUndefined()
expect(cleanEnv.TMPDIR).toBe("/tmp")
})
it("filters variables ending with _SECRET", () => {
// given
process.env.AWS_SECRET = "secret-value"
process.env.JWT_SECRET = "jwt-secret-token"
process.env.USER = "testuser"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.AWS_SECRET).toBeUndefined()
expect(cleanEnv.JWT_SECRET).toBeUndefined()
expect(cleanEnv.USER).toBe("testuser")
})
it("filters variables ending with _TOKEN", () => {
// given
process.env.ACCESS_TOKEN = "token-value"
process.env.BEARER_TOKEN = "bearer-token"
process.env.HOME = "/home/user"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.ACCESS_TOKEN).toBeUndefined()
expect(cleanEnv.BEARER_TOKEN).toBeUndefined()
expect(cleanEnv.HOME).toBe("/home/user")
})
it("filters variables ending with _PASSWORD", () => {
// given
process.env.DB_PASSWORD = "db-password"
process.env.APP_PASSWORD = "app-secret"
process.env.NODE_ENV = "production"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
expect(cleanEnv.APP_PASSWORD).toBeUndefined()
expect(cleanEnv.NODE_ENV).toBe("production")
})
it("filters variables ending with _CREDENTIAL", () => {
// given
process.env.GCP_CREDENTIAL = "json-credential"
process.env.AZURE_CREDENTIAL = "azure-creds"
process.env.PWD = "/current/dir"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.GCP_CREDENTIAL).toBeUndefined()
expect(cleanEnv.AZURE_CREDENTIAL).toBeUndefined()
expect(cleanEnv.PWD).toBe("/current/dir")
})
it("filters variables ending with _API_KEY", () => {
// given
// given
process.env.STRIPE_API_KEY = "sk_live_secret"
process.env.SENDGRID_API_KEY = "SG.secret"
process.env.SHELL = "/bin/zsh"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.STRIPE_API_KEY).toBeUndefined()
expect(cleanEnv.SENDGRID_API_KEY).toBeUndefined()
expect(cleanEnv.SHELL).toBe("/bin/zsh")
})
})
describe("safe environment variables preserved", () => {
it("preserves PATH", () => {
// given
process.env.PATH = "/usr/bin:/usr/local/bin"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.PATH).toBe("/usr/bin:/usr/local/bin")
})
it("preserves HOME", () => {
// given
process.env.HOME = "/home/testuser"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.HOME).toBe("/home/testuser")
})
it("preserves SHELL", () => {
// given
process.env.SHELL = "/bin/bash"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.SHELL).toBe("/bin/bash")
})
it("preserves LANG", () => {
// given
process.env.LANG = "en_US.UTF-8"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
})
it("preserves TERM", () => {
// given
process.env.TERM = "xterm-256color"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.TERM).toBe("xterm-256color")
})
it("preserves TMPDIR", () => {
// given
process.env.TMPDIR = "/tmp"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.TMPDIR).toBe("/tmp")
})
})

View File

@@ -1,28 +1,10 @@
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
// Also filters secret-containing env vars to prevent exposure to malicious stdio MCP servers (#B-02)
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
// npm/pnpm/yarn config patterns (original)
/^NPM_CONFIG_/i,
/^npm_config_/,
/^YARN_/,
/^PNPM_/,
/^NO_UPDATE_NOTIFIER$/,
// Specific high-risk secret env vars (explicit blocks)
/^ANTHROPIC_API_KEY$/i,
/^AWS_ACCESS_KEY_ID$/i,
/^AWS_SECRET_ACCESS_KEY$/i,
/^GITHUB_TOKEN$/i,
/^DATABASE_URL$/i,
/^OPENAI_API_KEY$/i,
// Suffix-based patterns for common secret naming conventions
/_KEY$/i,
/_SECRET$/i,
/_TOKEN$/i,
/_PASSWORD$/i,
/_CREDENTIAL$/i,
/_API_KEY$/i,
]
export function createCleanMcpEnvironment(

View File

@@ -279,116 +279,6 @@ describe("TaskToastManager", () => {
})
})
describe("model name display in task line", () => {
test("should show model name before category when modelInfo exists", () => {
// given - a task with category and modelInfo
const task = {
id: "task_model_display",
description: "Build UI component",
agent: "sisyphus-junior",
isBackground: true,
category: "deep",
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
}
// when - addTask is called
toastManager.addTask(task)
// then - toast should show model name before category like "gpt-5.3-codex: deep"
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("gpt-5.3-codex: deep")
expect(call.body.message).not.toContain("sisyphus-junior/deep")
})
test("should strip provider prefix from model name", () => {
// given - a task with provider-prefixed model
const task = {
id: "task_strip_provider",
description: "Fix styles",
agent: "sisyphus-junior",
isBackground: false,
category: "visual-engineering",
modelInfo: { model: "google/gemini-3.1-pro", type: "category-default" as const },
}
// when - addTask is called
toastManager.addTask(task)
// then - should show model ID without provider prefix
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("gemini-3.1-pro: visual-engineering")
})
test("should fall back to agent/category format when no modelInfo", () => {
// given - a task without modelInfo
const task = {
id: "task_no_model",
description: "Quick fix",
agent: "sisyphus-junior",
isBackground: true,
category: "quick",
}
// when - addTask is called
toastManager.addTask(task)
// then - should use old format with agent name
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("sisyphus-junior/quick")
})
test("should show model name without category when category is absent", () => {
// given - a task with modelInfo but no category
const task = {
id: "task_model_no_cat",
description: "Explore codebase",
agent: "explore",
isBackground: true,
modelInfo: { model: "anthropic/claude-sonnet-4-6", type: "category-default" as const },
}
// when - addTask is called
toastManager.addTask(task)
// then - should show just the model name in parens
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("(claude-sonnet-4-6)")
})
test("should show model name in queued tasks too", () => {
// given - a concurrency manager that limits to 1
const limitedConcurrency = {
getConcurrencyLimit: mock(() => 1),
} as unknown as ConcurrencyManager
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const limitedManager = new TaskToastManager(mockClient as any, limitedConcurrency)
limitedManager.addTask({
id: "task_running",
description: "Running task",
agent: "sisyphus-junior",
isBackground: true,
category: "deep",
modelInfo: { model: "openai/gpt-5.3-codex", type: "category-default" as const },
})
limitedManager.addTask({
id: "task_queued",
description: "Queued task",
agent: "sisyphus-junior",
isBackground: true,
category: "quick",
status: "queued",
modelInfo: { model: "anthropic/claude-haiku-4-5", type: "category-default" as const },
})
// when - the queued task toast fires
const lastCall = mockClient.tui.showToast.mock.calls[1][0]
// then - queued task should also show model name
expect(lastCall.body.message).toContain("claude-haiku-4-5: quick")
})
})
describe("updateTaskModelBySession", () => {
test("updates task model info and shows fallback toast", () => {
// given - task without model info

View File

@@ -127,13 +127,6 @@ export class TaskToastManager {
const queued = this.getQueuedTasks()
const concurrencyInfo = this.getConcurrencyInfo()
const formatTaskIdentifier = (task: TrackedTask): string => {
const modelName = task.modelInfo?.model?.split("/").pop()
if (modelName && task.category) return `${modelName}: ${task.category}`
if (modelName) return modelName
if (task.category) return `${task.agent}/${task.category}`
return task.agent
}
const lines: string[] = []
const isFallback = newTask.modelInfo && (
@@ -158,9 +151,9 @@ export class TaskToastManager {
const duration = this.formatDuration(task.startedAt)
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
const isNew = task.id === newTask.id ? " ← NEW" : ""
const taskId = formatTaskIdentifier(task)
const categoryInfo = task.category ? `/${task.category}` : ""
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - ${duration}${isNew}`)
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - ${duration}${isNew}`)
}
}
@@ -169,10 +162,10 @@ export class TaskToastManager {
lines.push(`Queued (${queued.length}):`)
for (const task of queued) {
const bgIcon = task.isBackground ? "[Q]" : "[W]"
const taskId = formatTaskIdentifier(task)
const categoryInfo = task.category ? `/${task.category}` : ""
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
const isNew = task.id === newTask.id ? " ← NEW" : ""
lines.push(`${bgIcon} ${task.description} (${taskId})${skillsInfo} - Queued${isNew}`)
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - Queued${isNew}`)
}
}

View File

@@ -58,8 +58,8 @@ describe("createAutoSlashCommandHook leak prevention", () => {
})
describe("#given hook with sessionProcessedCommandExecutions", () => {
describe("#when same command executed twice after fallback dedup window", () => {
it("#then second execution is treated as intentional rerun", async () => {
describe("#when same command executed twice within TTL for same session", () => {
it("#then second execution is deduplicated", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
@@ -68,61 +68,6 @@ describe("createAutoSlashCommandHook leak prevention", () => {
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(101)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
} finally {
nowSpy.mockRestore()
}
})
})
describe("#when same command is repeated within fallback dedup window", () => {
it("#then duplicate dispatch is suppressed", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input = createCommandInput("session-dedup", "leak-test-command")
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(99)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(1)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toBe("second")
} finally {
nowSpy.mockRestore()
}
})
})
describe("#when same event identifier is dispatched twice", () => {
it("#then second dispatch is deduplicated regardless of elapsed seconds", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input: CommandExecuteBeforeInput = {
...createCommandInput("session-dedup", "leak-test-command"),
eventID: "event-1",
}
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
@@ -138,6 +83,32 @@ describe("createAutoSlashCommandHook leak prevention", () => {
}
})
})
describe("#when same command is repeated after TTL expires", () => {
it("#then command executes again", async () => {
//#given
const nowSpy = spyOn(Date, "now")
try {
const hook = createAutoSlashCommandHook()
const input = createCommandInput("session-dedup", "leak-test-command")
const firstOutput = createCommandOutput("first")
const secondOutput = createCommandOutput("second")
//#when
nowSpy.mockReturnValue(0)
await hook["command.execute.before"](input, firstOutput)
nowSpy.mockReturnValue(30_001)
await hook["command.execute.before"](input, secondOutput)
//#then
expect(executeSlashCommandMock).toHaveBeenCalledTimes(2)
expect(firstOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
expect(secondOutput.parts[0].text).toContain(AUTO_SLASH_COMMAND_TAG_OPEN)
} finally {
nowSpy.mockRestore()
}
})
})
})
describe("#given hook with entries from multiple sessions", () => {

View File

@@ -1,98 +0,0 @@
import { describe, expect, it, mock } from "bun:test"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
mock.module("../../shared", () => ({
resolveCommandsInText: async (content: string) => content,
resolveFileReferencesInText: async (content: string) => content,
}))
mock.module("../../tools/slashcommand", () => ({
discoverCommandsSync: () => [
{
name: "shadowed",
metadata: { name: "shadowed", description: "builtin" },
content: "builtin template",
scope: "builtin",
},
{
name: "shadowed",
metadata: { name: "shadowed", description: "project" },
content: "project template",
scope: "project",
},
],
}))
mock.module("../../features/opencode-skill-loader", () => ({
discoverAllSkills: async (): Promise<LoadedSkill[]> => [],
}))
const { executeSlashCommand } = await import("./executor")
function createRestrictedSkill(): LoadedSkill {
return {
name: "restricted-skill",
definition: {
name: "restricted-skill",
description: "restricted",
template: "restricted template",
agent: "hephaestus",
},
scope: "user",
}
}
describe("executeSlashCommand resolution semantics", () => {
it("returns project command when project and builtin names collide", async () => {
//#given
const parsed = {
command: "shadowed",
args: "",
raw: "/shadowed",
}
//#when
const result = await executeSlashCommand(parsed, { skills: [] })
//#then
expect(result.success).toBe(true)
expect(result.replacementText).toContain("**Scope**: project")
expect(result.replacementText).toContain("project template")
expect(result.replacementText).not.toContain("builtin template")
})
it("blocks slash skill invocation when invoking agent is missing", async () => {
//#given
const parsed = {
command: "restricted-skill",
args: "",
raw: "/restricted-skill",
}
//#when
const result = await executeSlashCommand(parsed, { skills: [createRestrictedSkill()] })
//#then
expect(result.success).toBe(false)
expect(result.error).toBe('Skill "restricted-skill" is restricted to agent "hephaestus"')
})
it("allows slash skill invocation when invoking agent matches restriction", async () => {
//#given
const parsed = {
command: "restricted-skill",
args: "",
raw: "/restricted-skill",
}
//#when
const result = await executeSlashCommand(parsed, {
skills: [createRestrictedSkill()],
agent: "hephaestus",
})
//#then
expect(result.success).toBe(true)
expect(result.replacementText).toContain("restricted template")
})
})

View File

@@ -41,7 +41,6 @@ export interface ExecutorOptions {
skills?: LoadedSkill[]
pluginsEnabled?: boolean
enabledPluginsOverride?: Record<string, boolean>
agent?: string
}
function filterDiscoveredCommandsByScope(
@@ -61,12 +60,12 @@ async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandIn
const skillCommands = skills.map(skillToCommandInfo)
return [
...skillCommands,
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
...filterDiscoveredCommandsByScope(discoveredCommands, "builtin"),
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode-project"),
...filterDiscoveredCommandsByScope(discoveredCommands, "project"),
...filterDiscoveredCommandsByScope(discoveredCommands, "opencode"),
...filterDiscoveredCommandsByScope(discoveredCommands, "user"),
...skillCommands,
...filterDiscoveredCommandsByScope(discoveredCommands, "plugin"),
]
}
@@ -142,15 +141,6 @@ export async function executeSlashCommand(parsed: ParsedSlashCommand, options?:
}
}
if (command.scope === "skill" && command.metadata.agent) {
if (!options?.agent || command.metadata.agent !== options.agent) {
return {
success: false,
error: `Skill "${command.name}" is restricted to agent "${command.metadata.agent}"`,
}
}
}
try {
const template = await formatCommandTemplate(command, parsed.args)
return {

View File

@@ -18,8 +18,6 @@ import type {
} from "./types"
import type { LoadedSkill } from "../../features/opencode-skill-loader"
const COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS = 100
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
@@ -37,33 +35,6 @@ function getDeletedSessionID(properties: unknown): string | null {
return typeof info.id === "string" ? info.id : null
}
function getCommandExecutionEventID(input: CommandExecuteBeforeInput): string | null {
const candidateKeys = [
"messageID",
"messageId",
"eventID",
"eventId",
"invocationID",
"invocationId",
"commandID",
"commandId",
]
const recordInput = input as unknown
if (!isRecord(recordInput)) {
return null
}
for (const key of candidateKeys) {
const candidateValue = recordInput[key]
if (typeof candidateValue === "string" && candidateValue.length > 0) {
return candidateValue
}
}
return null
}
export interface AutoSlashCommandHookOptions {
skills?: LoadedSkill[]
pluginsEnabled?: boolean
@@ -125,12 +96,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
args: parsed.args,
})
const executionOptions: ExecutorOptions = {
...executorOptions,
agent: input.agent,
}
const result = await executeSlashCommand(parsed, executionOptions)
const result = await executeSlashCommand(parsed, executorOptions)
const idx = findSlashCommandPartIndex(output.parts)
if (idx < 0) {
@@ -159,10 +125,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
input: CommandExecuteBeforeInput,
output: CommandExecuteBeforeOutput
): Promise<void> => {
const eventID = getCommandExecutionEventID(input)
const commandKey = eventID
? `${input.sessionID}:event:${eventID}`
: `${input.sessionID}:fallback:${input.command.toLowerCase()}:${input.arguments || ""}`
const commandKey = `${input.sessionID}:${input.command.toLowerCase()}:${input.arguments || ""}`
if (sessionProcessedCommandExecutions.has(commandKey)) {
return
}
@@ -179,12 +142,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
}
const executionOptions: ExecutorOptions = {
...executorOptions,
agent: input.agent,
}
const result = await executeSlashCommand(parsed, executionOptions)
const result = await executeSlashCommand(parsed, executorOptions)
if (!result.success || !result.replacementText) {
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
@@ -195,10 +153,7 @@ export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions
return
}
sessionProcessedCommandExecutions.add(
commandKey,
eventID ? undefined : COMMAND_EXECUTE_FALLBACK_DEDUP_TTL_MS
)
sessionProcessedCommandExecutions.add(commandKey)
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`

View File

@@ -24,7 +24,7 @@ function removeSessionEntries(entries: Map<string, number>, sessionID: string):
export interface ProcessedCommandStore {
has(commandKey: string): boolean
add(commandKey: string, ttlMs?: number): void
add(commandKey: string): void
cleanupSession(sessionID: string): void
clear(): void
}
@@ -38,11 +38,11 @@ export function createProcessedCommandStore(): ProcessedCommandStore {
entries = pruneExpiredEntries(entries, now)
return entries.has(commandKey)
},
add(commandKey: string, ttlMs = PROCESSED_COMMAND_TTL_MS): void {
add(commandKey: string): void {
const now = Date.now()
entries = pruneExpiredEntries(entries, now)
entries.delete(commandKey)
entries.set(commandKey, now + ttlMs)
entries.set(commandKey, now + PROCESSED_COMMAND_TTL_MS)
entries = trimProcessedEntries(entries)
},
cleanupSession(sessionID: string): void {

View File

@@ -26,15 +26,6 @@ export interface CommandExecuteBeforeInput {
command: string
sessionID: string
arguments: string
agent?: string
messageID?: string
messageId?: string
eventID?: string
eventId?: string
invocationID?: string
invocationId?: string
commandID?: string
commandId?: string
}
export interface CommandExecuteBeforeOutput {

View File

@@ -1,9 +1,6 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync } from "node:fs"
import { join } from "node:path"
import { runBunInstallWithDetails } from "../../../cli/config-manager"
import { log } from "../../../shared/logger"
import { getOpenCodeCacheDir, getOpenCodeConfigPaths } from "../../../shared"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
import { extractChannel } from "../version-channel"
@@ -14,36 +11,9 @@ function getPinnedVersionToastMessage(latestVersion: string): string {
return `Update available: ${latestVersion} (version pinned, update manually)`
}
/**
* Resolves the active install workspace.
* Same logic as doctor check: prefer config-dir if installed, fall back to cache-dir.
*/
function resolveActiveInstallWorkspace(): string {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = getOpenCodeCacheDir()
const configInstallPath = join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json")
const cacheInstallPath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
// Prefer config-dir if installed there, otherwise fall back to cache-dir
if (existsSync(configInstallPath)) {
log(`[auto-update-checker] Active workspace: config-dir (${configPaths.configDir})`)
return configPaths.configDir
}
if (existsSync(cacheInstallPath)) {
log(`[auto-update-checker] Active workspace: cache-dir (${cacheDir})`)
return cacheDir
}
// Default to config-dir if neither exists (matches doctor behavior)
log(`[auto-update-checker] Active workspace: config-dir (default, no install detected)`)
return configPaths.configDir
}
async function runBunInstallSafe(workspaceDir: string): Promise<boolean> {
async function runBunInstallSafe(): Promise<boolean> {
try {
const result = await runBunInstallWithDetails({ outputMode: "pipe", workspaceDir })
const result = await runBunInstallWithDetails({ outputMode: "pipe" })
if (!result.success && result.error) {
log("[auto-update-checker] bun install error:", result.error)
}
@@ -112,8 +82,7 @@ export async function runBackgroundUpdateCheck(
invalidatePackage(PACKAGE_NAME)
const activeWorkspace = resolveActiveInstallWorkspace()
const installSuccess = await runBunInstallSafe(activeWorkspace)
const installSuccess = await runBunInstallSafe()
if (installSuccess) {
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)

View File

@@ -1,223 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
type PluginEntry = {
entry: string
isPinned: boolean
pinnedVersion: string | null
configPath: string
}
type ToastMessageGetter = (isUpdate: boolean, version?: string) => string
function createPluginEntry(overrides?: Partial<PluginEntry>): PluginEntry {
return {
entry: "oh-my-opencode@3.4.0",
isPinned: false,
pinnedVersion: null,
configPath: "/test/opencode.json",
...overrides,
}
}
const TEST_DIR = join(import.meta.dir, "__test-workspace-resolution__")
const TEST_CACHE_DIR = join(TEST_DIR, "cache")
const TEST_CONFIG_DIR = join(TEST_DIR, "config")
const mockFindPluginEntry = mock((_directory: string): PluginEntry | null => createPluginEntry())
const mockGetCachedVersion = mock((): string | null => "3.4.0")
const mockGetLatestVersion = mock(async (): Promise<string | null> => "3.5.0")
const mockExtractChannel = mock(() => "latest")
const mockInvalidatePackage = mock(() => {})
const mockShowUpdateAvailableToast = mock(
async (_ctx: PluginInput, _latestVersion: string, _getToastMessage: ToastMessageGetter): Promise<void> => {}
)
const mockShowAutoUpdatedToast = mock(
async (_ctx: PluginInput, _fromVersion: string, _toVersion: string): Promise<void> => {}
)
const mockSyncCachePackageJsonToIntent = mock(() => ({ synced: true, error: null }))
const mockRunBunInstallWithDetails = mock(
async (opts?: { outputMode?: string; workspaceDir?: string }) => {
return { success: true }
}
)
mock.module("../checker", () => ({
findPluginEntry: mockFindPluginEntry,
getCachedVersion: mockGetCachedVersion,
getLatestVersion: mockGetLatestVersion,
revertPinnedVersion: mock(() => false),
syncCachePackageJsonToIntent: mockSyncCachePackageJsonToIntent,
}))
mock.module("../version-channel", () => ({ extractChannel: mockExtractChannel }))
mock.module("../cache", () => ({ invalidatePackage: mockInvalidatePackage }))
mock.module("../../../cli/config-manager", () => ({
runBunInstallWithDetails: mockRunBunInstallWithDetails,
}))
mock.module("./update-toasts", () => ({
showUpdateAvailableToast: mockShowUpdateAvailableToast,
showAutoUpdatedToast: mockShowAutoUpdatedToast,
}))
mock.module("../../../shared/logger", () => ({ log: () => {} }))
mock.module("../../../shared", () => ({
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
getOpenCodeConfigPaths: () => ({
configDir: TEST_CONFIG_DIR,
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
packageJson: join(TEST_CONFIG_DIR, "package.json"),
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
}),
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
}))
// Mock constants BEFORE importing the module
const ORIGINAL_PACKAGE_NAME = "oh-my-opencode"
mock.module("../constants", () => ({
PACKAGE_NAME: ORIGINAL_PACKAGE_NAME,
CACHE_DIR: TEST_CACHE_DIR,
USER_CONFIG_DIR: TEST_CONFIG_DIR,
}))
// Need to mock getOpenCodeCacheDir and getOpenCodeConfigPaths before importing the module
mock.module("../../../shared/data-path", () => ({
getDataDir: () => join(TEST_DIR, "data"),
getOpenCodeStorageDir: () => join(TEST_DIR, "data", "opencode", "storage"),
getCacheDir: () => TEST_DIR,
getOmoOpenCodeCacheDir: () => join(TEST_DIR, "oh-my-opencode"),
getOpenCodeCacheDir: () => TEST_CACHE_DIR,
}))
mock.module("../../../shared/opencode-config-dir", () => ({
getOpenCodeConfigDir: () => TEST_CONFIG_DIR,
getOpenCodeConfigPaths: () => ({
configDir: TEST_CONFIG_DIR,
configJson: join(TEST_CONFIG_DIR, "opencode.json"),
configJsonc: join(TEST_CONFIG_DIR, "opencode.jsonc"),
packageJson: join(TEST_CONFIG_DIR, "package.json"),
omoConfig: join(TEST_CONFIG_DIR, "oh-my-opencode.json"),
}),
}))
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
describe("workspace resolution", () => {
const mockCtx = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
isUpdate ? `Update to ${version}` : "Up to date"
beforeEach(() => {
// Setup test directories
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_DIR, { recursive: true })
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
mockGetLatestVersion.mockReset()
mockExtractChannel.mockReset()
mockInvalidatePackage.mockReset()
mockRunBunInstallWithDetails.mockReset()
mockShowUpdateAvailableToast.mockReset()
mockShowAutoUpdatedToast.mockReset()
mockFindPluginEntry.mockReturnValue(createPluginEntry())
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.5.0")
mockExtractChannel.mockReturnValue("latest")
// Note: Don't use mockResolvedValue here - it overrides the function that captures args
mockSyncCachePackageJsonToIntent.mockReturnValue({ synced: true, error: null })
})
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
})
describe("#given config-dir install exists but cache-dir does not", () => {
it("installs to config-dir, not cache-dir", async () => {
//#given - config-dir has installation, cache-dir does not
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_CONFIG_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
)
writeFileSync(
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
)
// cache-dir should NOT exist
expect(existsSync(TEST_CACHE_DIR)).toBe(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then - install should be called with config-dir
const mockCalls = mockRunBunInstallWithDetails.mock.calls
expect(mockCalls[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
})
})
describe("#given both config-dir and cache-dir exist", () => {
it("prefers config-dir over cache-dir", async () => {
//#given - both directories have installations
mkdirSync(join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_CONFIG_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
)
writeFileSync(
join(TEST_CONFIG_DIR, "node_modules", "oh-my-opencode", "package.json"),
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
)
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
)
writeFileSync(
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then - install should prefer config-dir
const mockCalls2 = mockRunBunInstallWithDetails.mock.calls
expect(mockCalls2[0][0]?.workspaceDir).toBe(TEST_CONFIG_DIR)
})
})
describe("#given only cache-dir install exists", () => {
it("falls back to cache-dir", async () => {
//#given - only cache-dir has installation
mkdirSync(join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "3.4.0" } }, null, 2)
)
writeFileSync(
join(TEST_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
JSON.stringify({ name: "oh-my-opencode", version: "3.4.0" }, null, 2)
)
// config-dir should NOT exist
expect(existsSync(TEST_CONFIG_DIR)).toBe(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then - install should fall back to cache-dir
const mockCalls3 = mockRunBunInstallWithDetails.mock.calls
expect(mockCalls3[0][0]?.workspaceDir).toBe(TEST_CACHE_DIR)
})
})
})

View File

@@ -52,4 +52,3 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
export { createReadImageResizerHook } from "./read-image-resizer"
export { createTodoDescriptionOverrideHook } from "./todo-description-override"

View File

@@ -1,96 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./constants"
import { ULTRAWORK_VERIFICATION_PROMISE } from "./constants"
import type { RalphLoopState } from "./types"
import { handleFailedVerification } from "./verification-failure-handler"
import { withTimeout } from "./with-timeout"
type OpenCodeSessionMessage = {
info?: { role?: string }
parts?: Array<{ type?: string; text?: string }>
}
const ORACLE_AGENT_PATTERN = /Agent:\s*oracle/i
const TASK_METADATA_SESSION_PATTERN = /<task_metadata>[\s\S]*?session_id:\s*([^\s<]+)[\s\S]*?<\/task_metadata>/i
const VERIFIED_PROMISE_PATTERN = new RegExp(
`<promise>\\s*${ULTRAWORK_VERIFICATION_PROMISE}\\s*<\\/promise>`,
"i",
)
function collectAssistantText(message: OpenCodeSessionMessage): string {
if (!Array.isArray(message.parts)) {
return ""
}
let text = ""
for (const part of message.parts) {
if (part.type !== "text") {
continue
}
text += `${text ? "\n" : ""}${part.text ?? ""}`
}
return text
}
async function detectOracleVerificationFromParentSession(
ctx: PluginInput,
parentSessionID: string,
directory: string,
apiTimeoutMs: number,
): Promise<string | undefined> {
try {
const response = await withTimeout(
ctx.client.session.messages({
path: { id: parentSessionID },
query: { directory },
}),
apiTimeoutMs,
)
const messagesResponse: unknown = response
const responseData =
typeof messagesResponse === "object" && messagesResponse !== null && "data" in messagesResponse
? (messagesResponse as { data?: unknown }).data
: undefined
const messageArray: unknown[] = Array.isArray(messagesResponse)
? messagesResponse
: Array.isArray(responseData)
? responseData
: []
for (let index = messageArray.length - 1; index >= 0; index -= 1) {
const message = messageArray[index] as OpenCodeSessionMessage
if (message.info?.role !== "assistant") {
continue
}
const assistantText = collectAssistantText(message)
if (!VERIFIED_PROMISE_PATTERN.test(assistantText) || !ORACLE_AGENT_PATTERN.test(assistantText)) {
continue
}
const sessionMatch = assistantText.match(TASK_METADATA_SESSION_PATTERN)
const detectedOracleSessionID = sessionMatch?.[1]?.trim()
if (detectedOracleSessionID) {
return detectedOracleSessionID
}
}
return undefined
} catch (error) {
log(`[${HOOK_NAME}] Failed to scan parent session for oracle verification evidence`, {
parentSessionID,
error: String(error),
})
return undefined
}
}
type LoopStateController = {
restartAfterFailedVerification: (sessionID: string, messageCountAtStart?: number) => RalphLoopState | null
setVerificationSessionID: (sessionID: string, verificationSessionID: string) => RalphLoopState | null
}
export async function handlePendingVerification(
@@ -118,29 +33,6 @@ export async function handlePendingVerification(
} = input
if (matchesParentSession || (verificationSessionID && matchesVerificationSession)) {
if (!verificationSessionID && state.session_id) {
const recoveredVerificationSessionID = await detectOracleVerificationFromParentSession(
ctx,
state.session_id,
directory,
apiTimeoutMs,
)
if (recoveredVerificationSessionID) {
const updatedState = loopState.setVerificationSessionID(
state.session_id,
recoveredVerificationSessionID,
)
if (updatedState) {
log(`[${HOOK_NAME}] Recovered missing verification session from parent evidence`, {
parentSessionID: state.session_id,
recoveredVerificationSessionID,
})
return
}
}
}
const restarted = await handleFailedVerification(ctx, {
state,
loopState,

View File

@@ -136,13 +136,6 @@ export function createRalphLoopEventHandler(
}
if (state.verification_pending) {
if (!verificationSessionID && matchesParentSession) {
log(`[${HOOK_NAME}] Verification pending without tracked oracle session, running recovery check`, {
sessionID,
iteration: state.iteration,
})
}
await handlePendingVerification(ctx, {
sessionID,
state,

View File

@@ -101,13 +101,7 @@ export function createAutoRetryHelpers(deps: HookDeps) {
return
}
const agentSettings = resolvedAgent
? pluginConfig?.agents?.[resolvedAgent as keyof typeof pluginConfig.agents]
: undefined
const retryModelPayload = buildRetryModelPayload(newModel, agentSettings ? {
variant: agentSettings.variant,
reasoningEffort: agentSettings.reasoningEffort,
} : undefined)
const retryModelPayload = buildRetryModelPayload(newModel)
if (!retryModelPayload) {
log(`[${HOOK_NAME}] Invalid model format (missing provider prefix): ${newModel}`)
const state = sessionStates.get(sessionID)

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { classifyErrorType, extractAutoRetrySignal, extractStatusCode, isRetryableError } from "./error-classifier"
import { classifyErrorType, extractAutoRetrySignal, isRetryableError } from "./error-classifier"
describe("runtime-fallback error classifier", () => {
test("detects cooling-down auto-retry status signals", () => {
@@ -97,72 +97,3 @@ describe("runtime-fallback error classifier", () => {
expect(signal).toBeUndefined()
})
})
describe("extractStatusCode", () => {
test("extracts numeric statusCode from top-level", () => {
expect(extractStatusCode({ statusCode: 429 })).toBe(429)
})
test("extracts numeric status from top-level", () => {
expect(extractStatusCode({ status: 503 })).toBe(503)
})
test("extracts statusCode from nested data", () => {
expect(extractStatusCode({ data: { statusCode: 500 } })).toBe(500)
})
test("extracts statusCode from nested error", () => {
expect(extractStatusCode({ error: { statusCode: 502 } })).toBe(502)
})
test("extracts statusCode from nested cause", () => {
expect(extractStatusCode({ cause: { statusCode: 504 } })).toBe(504)
})
test("skips non-numeric status and finds deeper numeric statusCode", () => {
//#given — status is a string, but error.statusCode is numeric
const error = {
status: "error",
error: { statusCode: 429 },
}
//#when
const code = extractStatusCode(error)
//#then
expect(code).toBe(429)
})
test("skips non-numeric statusCode string and finds numeric in cause", () => {
const error = {
statusCode: "UNKNOWN",
status: "failed",
cause: { statusCode: 503 },
}
expect(extractStatusCode(error)).toBe(503)
})
test("returns undefined when no numeric status exists", () => {
expect(extractStatusCode({ status: "error", message: "something broke" })).toBeUndefined()
})
test("returns undefined for null/undefined error", () => {
expect(extractStatusCode(null)).toBeUndefined()
expect(extractStatusCode(undefined)).toBeUndefined()
})
test("falls back to regex match in error message", () => {
const error = { message: "Request failed with status code 429" }
expect(extractStatusCode(error, [429, 503])).toBe(429)
})
test("prefers top-level numeric over nested numeric", () => {
const error = {
statusCode: 400,
error: { statusCode: 429 },
cause: { statusCode: 503 },
}
expect(extractStatusCode(error)).toBe(400)
})
})

View File

@@ -33,15 +33,8 @@ export function extractStatusCode(error: unknown, retryOnErrors?: number[]): num
const errorObj = error as Record<string, unknown>
const statusCode = [
errorObj.statusCode,
errorObj.status,
(errorObj.data as Record<string, unknown>)?.statusCode,
(errorObj.error as Record<string, unknown>)?.statusCode,
(errorObj.cause as Record<string, unknown>)?.statusCode,
].find((code): code is number => typeof code === "number")
if (statusCode !== undefined) {
const statusCode = errorObj.statusCode ?? errorObj.status ?? (errorObj.data as Record<string, unknown>)?.statusCode
if (typeof statusCode === "number") {
return statusCode
}

View File

@@ -1,114 +0,0 @@
import { describe, test, expect } from "bun:test"
import { buildRetryModelPayload } from "./retry-model-payload"
describe("buildRetryModelPayload", () => {
test("should return undefined for empty model string", () => {
// given
const model = ""
// when
const result = buildRetryModelPayload(model)
// then
expect(result).toBeUndefined()
})
test("should return undefined for model without provider prefix", () => {
// given
const model = "kimi-k2.5"
// when
const result = buildRetryModelPayload(model)
// then
expect(result).toBeUndefined()
})
test("should parse provider and model ID", () => {
// given
const model = "chutes/kimi-k2.5"
// when
const result = buildRetryModelPayload(model)
// then
expect(result).toEqual({
model: { providerID: "chutes", modelID: "kimi-k2.5" },
})
})
test("should include variant from model string", () => {
// given
const model = "anthropic/claude-sonnet-4-5 high"
// when
const result = buildRetryModelPayload(model)
// then
expect(result).toEqual({
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
variant: "high",
})
})
test("should use agent variant when model string has no variant", () => {
// given
const model = "chutes/kimi-k2.5"
const agentSettings = { variant: "max" }
// when
const result = buildRetryModelPayload(model, agentSettings)
// then
expect(result).toEqual({
model: { providerID: "chutes", modelID: "kimi-k2.5" },
variant: "max",
})
})
test("should prefer model string variant over agent variant", () => {
// given
const model = "anthropic/claude-sonnet-4-5 high"
const agentSettings = { variant: "max" }
// when
const result = buildRetryModelPayload(model, agentSettings)
// then
expect(result).toEqual({
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
variant: "high",
})
})
test("should include reasoningEffort from agent settings", () => {
// given
const model = "openai/gpt-5.4"
const agentSettings = { variant: "high", reasoningEffort: "xhigh" }
// when
const result = buildRetryModelPayload(model, agentSettings)
// then
expect(result).toEqual({
model: { providerID: "openai", modelID: "gpt-5.4" },
variant: "high",
reasoningEffort: "xhigh",
})
})
test("should not include reasoningEffort when agent settings has none", () => {
// given
const model = "chutes/kimi-k2.5"
const agentSettings = { variant: "medium" }
// when
const result = buildRetryModelPayload(model, agentSettings)
// then
expect(result).toEqual({
model: { providerID: "chutes", modelID: "kimi-k2.5" },
variant: "medium",
})
})
})

View File

@@ -2,29 +2,24 @@ import { parseModelString } from "../../tools/delegate-task/model-string-parser"
export function buildRetryModelPayload(
model: string,
agentSettings?: { variant?: string; reasoningEffort?: string },
): { model: { providerID: string; modelID: string }; variant?: string; reasoningEffort?: string } | undefined {
): { model: { providerID: string; modelID: string }; variant?: string } | undefined {
const parsedModel = parseModelString(model)
if (!parsedModel) {
return undefined
}
const variant = parsedModel.variant ?? agentSettings?.variant
const reasoningEffort = agentSettings?.reasoningEffort
const payload: { model: { providerID: string; modelID: string }; variant?: string; reasoningEffort?: string } = {
model: {
providerID: parsedModel.providerID,
modelID: parsedModel.modelID,
},
}
if (variant) {
payload.variant = variant
}
if (reasoningEffort) {
payload.reasoningEffort = reasoningEffort
}
return payload
return parsedModel.variant
? {
model: {
providerID: parsedModel.providerID,
modelID: parsedModel.modelID,
},
variant: parsedModel.variant,
}
: {
model: {
providerID: parsedModel.providerID,
modelID: parsedModel.modelID,
},
}
}

View File

@@ -18,7 +18,7 @@ describe("createSessionStateStore regressions", () => {
describe("#given external activity happens after a successful continuation", () => {
describe("#when todos stay unchanged", () => {
test("#then it keeps counting stagnation", () => {
test("#then it treats the activity as progress instead of stagnation", () => {
const sessionID = "ses-activity-progress"
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
@@ -37,9 +37,9 @@ describe("createSessionStateStore regressions", () => {
trackedState.abortDetectedAt = undefined
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
expect(progressUpdate.hasProgressed).toBe(false)
expect(progressUpdate.progressSource).toBe("none")
expect(progressUpdate.stagnationCount).toBe(1)
expect(progressUpdate.hasProgressed).toBe(true)
expect(progressUpdate.progressSource).toBe("activity")
expect(progressUpdate.stagnationCount).toBe(0)
})
})
})
@@ -72,7 +72,7 @@ describe("createSessionStateStore regressions", () => {
describe("#given stagnation already halted a session", () => {
describe("#when new activity appears before the next idle check", () => {
test("#then it does not reset the stop condition", () => {
test("#then it resets the stop condition on the next progress check", () => {
const sessionID = "ses-stagnation-recovery"
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
@@ -96,9 +96,9 @@ describe("createSessionStateStore regressions", () => {
const progressUpdate = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
expect(progressUpdate.previousStagnationCount).toBe(MAX_STAGNATION_COUNT)
expect(progressUpdate.hasProgressed).toBe(false)
expect(progressUpdate.progressSource).toBe("none")
expect(progressUpdate.stagnationCount).toBe(MAX_STAGNATION_COUNT)
expect(progressUpdate.hasProgressed).toBe(true)
expect(progressUpdate.progressSource).toBe("activity")
expect(progressUpdate.stagnationCount).toBe(0)
})
})
})

View File

@@ -16,6 +16,8 @@ interface TrackedSessionState {
lastAccessedAt: number
lastCompletedCount?: number
lastTodoSnapshot?: string
activitySignalCount: number
lastObservedActivitySignalCount?: number
}
export interface ContinuationProgressUpdate {
@@ -23,7 +25,7 @@ export interface ContinuationProgressUpdate {
previousStagnationCount: number
stagnationCount: number
hasProgressed: boolean
progressSource: "none" | "todo"
progressSource: "none" | "todo" | "activity"
}
export interface SessionStateStore {
@@ -96,7 +98,17 @@ export function createSessionStateStore(): SessionStateStore {
const trackedSession: TrackedSessionState = {
state: rawState,
lastAccessedAt: Date.now(),
activitySignalCount: 0,
}
trackedSession.state = new Proxy(rawState, {
set(target, property, value, receiver) {
if (property === "abortDetectedAt" && value === undefined) {
trackedSession.activitySignalCount += 1
}
return Reflect.set(target, property, value, receiver)
},
})
sessions.set(sessionID, trackedSession)
return trackedSession
}
@@ -125,6 +137,7 @@ export function createSessionStateStore(): SessionStateStore {
const previousStagnationCount = state.stagnationCount
const currentCompletedCount = todos?.filter((todo) => todo.status === "completed").length
const currentTodoSnapshot = todos ? getTodoSnapshot(todos) : undefined
const currentActivitySignalCount = trackedSession.activitySignalCount
const hasCompletedMoreTodos =
currentCompletedCount !== undefined
&& trackedSession.lastCompletedCount !== undefined
@@ -133,6 +146,9 @@ export function createSessionStateStore(): SessionStateStore {
currentTodoSnapshot !== undefined
&& trackedSession.lastTodoSnapshot !== undefined
&& currentTodoSnapshot !== trackedSession.lastTodoSnapshot
const hasObservedExternalActivity =
trackedSession.lastObservedActivitySignalCount !== undefined
&& currentActivitySignalCount > trackedSession.lastObservedActivitySignalCount
const hadSuccessfulInjectionAwaitingProgressCheck = state.awaitingPostInjectionProgressCheck === true
state.lastIncompleteCount = incompleteCount
@@ -142,6 +158,7 @@ export function createSessionStateStore(): SessionStateStore {
if (currentTodoSnapshot !== undefined) {
trackedSession.lastTodoSnapshot = currentTodoSnapshot
}
trackedSession.lastObservedActivitySignalCount = currentActivitySignalCount
if (previousIncompleteCount === undefined) {
state.stagnationCount = 0
@@ -156,7 +173,9 @@ export function createSessionStateStore(): SessionStateStore {
const progressSource = incompleteCount < previousIncompleteCount || hasCompletedMoreTodos || hasTodoSnapshotChanged
? "todo"
: "none"
: hasObservedExternalActivity
? "activity"
: "none"
if (progressSource !== "none") {
state.stagnationCount = 0
@@ -204,6 +223,8 @@ export function createSessionStateStore(): SessionStateStore {
state.awaitingPostInjectionProgressCheck = false
trackedSession.lastCompletedCount = undefined
trackedSession.lastTodoSnapshot = undefined
trackedSession.activitySignalCount = 0
trackedSession.lastObservedActivitySignalCount = undefined
}
function cancelCountdown(sessionID: string): void {

View File

@@ -3,8 +3,6 @@
import { describe, expect, it as test } from "bun:test"
import { MAX_STAGNATION_COUNT } from "./constants"
import { handleNonIdleEvent } from "./non-idle-events"
import { createSessionStateStore } from "./session-state"
import { shouldStopForStagnation } from "./stagnation-detection"
describe("shouldStopForStagnation", () => {
@@ -27,7 +25,7 @@ describe("shouldStopForStagnation", () => {
})
})
describe("#when todo progress is detected after the halt", () => {
describe("#when activity progress is detected after the halt", () => {
test("#then it clears the stop condition", () => {
const shouldStop = shouldStopForStagnation({
sessionID: "ses-recovered",
@@ -37,7 +35,7 @@ describe("shouldStopForStagnation", () => {
previousStagnationCount: MAX_STAGNATION_COUNT,
stagnationCount: 0,
hasProgressed: true,
progressSource: "todo",
progressSource: "activity",
},
})
@@ -45,60 +43,4 @@ describe("shouldStopForStagnation", () => {
})
})
})
describe("#given only non-idle tool and message events happen between idle checks", () => {
describe("#when todo state does not change across three idle cycles", () => {
test("#then stagnation count reaches three", () => {
// given
const sessionStateStore = createSessionStateStore()
const sessionID = "ses-non-idle-activity-without-progress"
const state = sessionStateStore.getState(sessionID)
const todos = [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "pending", priority: "medium" },
]
sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
// when
state.awaitingPostInjectionProgressCheck = true
const firstCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
handleNonIdleEvent({
eventType: "tool.execute.before",
properties: { sessionID },
sessionStateStore,
})
handleNonIdleEvent({
eventType: "message.updated",
properties: { info: { sessionID, role: "assistant" } },
sessionStateStore,
})
state.awaitingPostInjectionProgressCheck = true
const secondCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
handleNonIdleEvent({
eventType: "tool.execute.after",
properties: { sessionID },
sessionStateStore,
})
handleNonIdleEvent({
eventType: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } },
sessionStateStore,
})
state.awaitingPostInjectionProgressCheck = true
const thirdCycle = sessionStateStore.trackContinuationProgress(sessionID, 2, todos)
// then
expect(firstCycle.stagnationCount).toBe(1)
expect(secondCycle.stagnationCount).toBe(2)
expect(thirdCycle.stagnationCount).toBe(3)
sessionStateStore.shutdown()
})
})
})
})

View File

@@ -1,28 +0,0 @@
export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work.
## Todo Format (MANDATORY)
Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT.
Format: "[WHERE] [HOW] to [WHY] — expect [RESULT]"
GOOD:
- "src/utils/validation.ts: Add validateEmail() for input sanitization — returns boolean"
- "UserService.create(): Call validateEmail() before DB insert — rejects invalid emails with 400"
- "validation.test.ts: Add test for missing @ sign — expect validateEmail('foo') to return false"
BAD:
- "Implement email validation" (where? how? what result?)
- "Add dark mode" (this is a feature, not a todo)
- "Fix auth" (what file? what changes? what's expected?)
## Granularity Rules
Each todo MUST be a single atomic action completable in 1-3 tool calls. If it needs more, split it.
**Size test**: Can you complete this todo by editing one file or running one command? If not, it's too big.
## Task Management
- One in_progress at a time. Complete it before starting the next.
- Mark completed immediately after finishing each item.
- Skip this tool for single trivial tasks (one-step, obvious action).`

View File

@@ -1,14 +0,0 @@
import { TODOWRITE_DESCRIPTION } from "./description"
export function createTodoDescriptionOverrideHook() {
return {
"tool.definition": async (
input: { toolID: string },
output: { description: string; parameters: unknown },
) => {
if (input.toolID === "todowrite") {
output.description = TODOWRITE_DESCRIPTION
}
},
}
}

View File

@@ -1,40 +0,0 @@
import { describe, it, expect } from "bun:test"
import { createTodoDescriptionOverrideHook } from "./hook"
import { TODOWRITE_DESCRIPTION } from "./description"
describe("createTodoDescriptionOverrideHook", () => {
describe("#given hook is created", () => {
describe("#when tool.definition is called with todowrite", () => {
it("#then should override the description", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "todowrite" }, output)
expect(output.description).toBe(TODOWRITE_DESCRIPTION)
})
})
describe("#when tool.definition is called with non-todowrite tool", () => {
it("#then should not modify the description", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "bash" }, output)
expect(output.description).toBe("original description")
})
})
describe("#when tool.definition is called with TodoWrite (case-insensitive)", () => {
it("#then should not override for different casing since OpenCode sends lowercase", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "TodoWrite" }, output)
expect(output.description).toBe("original description")
})
})
})
})

View File

@@ -1 +0,0 @@
export { createTodoDescriptionOverrideHook } from "./hook"

View File

@@ -71,9 +71,5 @@ export function createPluginInterface(args: {
ctx,
hooks,
}),
"tool.definition": async (input, output) => {
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
},
}
}

View File

@@ -1,15 +1,8 @@
import { describe, it, expect, afterEach } from "bun:test"
import { describe, it, expect } from "bun:test"
import { createEventHandler } from "./event"
import { createChatMessageHandler } from "./chat-message"
import { _resetForTesting, setMainSession } from "../features/claude-code-session-state"
import { clearPendingModelFallback, createModelFallbackHook } from "../hooks/model-fallback/hook"
type EventInput = { event: { type: string; properties?: unknown } }
afterEach(() => {
_resetForTesting()
})
type EventInput = { event: { type: string; properties?: Record<string, unknown> } }
describe("createEventHandler - idle deduplication", () => {
it("Order A (status→idle): synthetic idle deduped - real idle not dispatched again", async () => {
@@ -73,7 +66,7 @@ afterEach(() => {
//#then - synthetic idle dispatched once
expect(dispatchCalls.length).toBe(1)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
//#when - real session.idle arrives
await eventHandler({
@@ -149,7 +142,7 @@ afterEach(() => {
//#then - real idle dispatched once
expect(dispatchCalls.length).toBe(1)
expect(dispatchCalls[0].event.type).toBe("session.idle")
expect((dispatchCalls[0].event.properties as { sessionID?: string } | undefined)?.sessionID).toBe(sessionId)
expect(dispatchCalls[0].event.properties?.sessionID).toBe(sessionId)
//#when - session.status with idle (generates synthetic idle)
await eventHandler({
@@ -252,7 +245,7 @@ afterEach(() => {
event: {
type: "message.updated",
},
} as any)
})
//#then - both maps should be pruned (no dedup should occur for new events)
// We verify by checking that a new idle event for same session is dispatched
@@ -294,7 +287,7 @@ afterEach(() => {
stopContinuationGuard: { event: async () => {} },
compactionTodoPreserver: { event: async () => {} },
atlasHook: { handler: async () => {} },
} as any,
},
})
await eventHandlerWithMock({
@@ -433,7 +426,7 @@ describe("createEventHandler - event forwarding", () => {
type: "session.deleted",
properties: { info: { id: sessionID } },
},
} as any)
})
//#then
expect(forwardedEvents.length).toBe(1)
@@ -442,146 +435,3 @@ describe("createEventHandler - event forwarding", () => {
expect(deletedSessions).toEqual([sessionID])
})
})
describe("createEventHandler - retry dedupe lifecycle", () => {
it("re-handles same retry key after session recovers to idle status", async () => {
//#given
const sessionID = "ses_retry_recovery_rearm"
setMainSession(sessionID)
clearPendingModelFallback(sessionID)
const abortCalls: string[] = []
const promptCalls: string[] = []
const modelFallback = createModelFallbackHook()
const eventHandler = createEventHandler({
ctx: {
directory: "/tmp",
client: {
session: {
abort: async ({ path }: { path: { id: string } }) => {
abortCalls.push(path.id)
return {}
},
prompt: async ({ path }: { path: { id: string } }) => {
promptCalls.push(path.id)
return {}
},
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
skillMcpManager: {
disconnectSession: async () => {},
},
} as any,
hooks: {
modelFallback,
stopContinuationGuard: { isStopped: () => false },
} as any,
})
const chatMessageHandler = createChatMessageHandler({
ctx: {
client: {
tui: {
showToast: async () => ({}),
},
},
} as any,
pluginConfig: {} as any,
firstMessageVariantGate: {
shouldOverride: () => false,
markApplied: () => {},
},
hooks: {
modelFallback,
stopContinuationGuard: null,
keywordDetector: null,
claudeCodeHooks: null,
autoSlashCommand: null,
startWork: null,
ralphLoop: null,
} as any,
})
const retryStatus = {
type: "retry",
attempt: 1,
message: "All credentials for model claude-opus-4-6-thinking are cooling down [retrying in 7m 56s attempt #1]",
next: 476,
} as const
await eventHandler({
event: {
type: "message.updated",
properties: {
info: {
id: "msg_user_retry_rearm",
sessionID,
role: "user",
modelID: "claude-opus-4-6-thinking",
providerID: "anthropic",
agent: "Sisyphus (Ultraworker)",
},
},
},
} as any)
//#when - first retry key is handled
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID,
status: retryStatus,
},
},
} as any)
const firstOutput = { message: {}, parts: [] as Array<{ type: string; text?: string }> }
await chatMessageHandler(
{
sessionID,
agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-6-thinking" },
},
firstOutput,
)
//#when - session recovers to non-retry idle state
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID,
status: { type: "idle" },
},
},
} as any)
//#when - same retry key appears again after recovery
await eventHandler({
event: {
type: "session.status",
properties: {
sessionID,
status: retryStatus,
},
},
} as any)
//#then
expect(abortCalls).toEqual([sessionID, sessionID])
expect(promptCalls).toEqual([sessionID, sessionID])
})
})

View File

@@ -421,12 +421,6 @@ export function createEventHandler(args: {
const sessionID = props?.sessionID as string | undefined;
const status = props?.status as { type?: string; attempt?: number; message?: string; next?: number } | undefined;
// Retry dedupe lifecycle: set key when a retry status is handled, clear it after recovery
// (non-retry idle) so future failures with the same key can trigger fallback again.
if (sessionID && status?.type === "idle") {
lastHandledRetryStatusKey.delete(sessionID);
}
if (sessionID && status?.type === "retry" && isModelFallbackEnabled && !isRuntimeFallbackEnabled) {
try {
const retryMessage = typeof status.message === "string" ? status.message : "";

View File

@@ -14,7 +14,6 @@ import {
createHashlineReadEnhancerHook,
createReadImageResizerHook,
createJsonErrorRecoveryHook,
createTodoDescriptionOverrideHook,
} from "../../hooks"
import {
getOpenCodeVersion,
@@ -36,7 +35,6 @@ export type ToolGuardHooks = {
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
todoDescriptionOverride: ReturnType<typeof createTodoDescriptionOverrideHook> | null
}
export function createToolGuardHooks(args: {
@@ -113,10 +111,6 @@ export function createToolGuardHooks(args: {
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
: null
const todoDescriptionOverride = isHookEnabled("todo-description-override")
? safeHook("todo-description-override", () => createTodoDescriptionOverrideHook())
: null
return {
commentChecker,
toolOutputTruncator,
@@ -129,6 +123,5 @@ export function createToolGuardHooks(args: {
hashlineReadEnhancer,
jsonErrorRecovery,
readImageResizer,
todoDescriptionOverride,
}
}

View File

@@ -48,50 +48,22 @@ export function createToolExecuteAfterHandler(args: {
const prompt = typeof output.metadata?.prompt === "string" ? output.metadata.prompt : undefined
const verificationAttemptId = prompt?.match(VERIFICATION_ATTEMPT_PATTERN)?.[1]?.trim()
const loopState = directory ? readState(directory) : null
const isVerificationContext =
if (
agent === "oracle"
&& !!sessionId
&& !!directory
&& sessionId
&& verificationAttemptId
&& directory
&& loopState?.active === true
&& loopState.ultrawork === true
&& loopState.verification_pending === true
&& loopState.session_id === input.sessionID
log("[tool-execute-after] ULW verification tracking check", {
tool: input.tool,
agent,
parentSessionID: input.sessionID,
oracleSessionID: sessionId,
hasPromptInMetadata: typeof prompt === "string",
extractedVerificationAttemptId: verificationAttemptId,
})
if (
isVerificationContext
&& verificationAttemptId
&& loopState.verification_attempt_id === verificationAttemptId
) {
writeState(directory, {
...loopState,
verification_session_id: sessionId,
})
log("[tool-execute-after] Stored oracle verification session via attempt match", {
parentSessionID: input.sessionID,
oracleSessionID: sessionId,
verificationAttemptId,
})
} else if (isVerificationContext && !verificationAttemptId) {
writeState(directory, {
...loopState,
verification_session_id: sessionId,
})
log("[tool-execute-after] Fallback: stored oracle verification session without attempt match", {
parentSessionID: input.sessionID,
oracleSessionID: sessionId,
hasPromptInMetadata: typeof prompt === "string",
expectedAttemptId: loopState.verification_attempt_id,
extractedAttemptId: verificationAttemptId,
})
}
}

View File

@@ -79,12 +79,6 @@ export function createToolExecuteBeforeHandler(args: {
if (shouldInjectOracleVerification) {
const verificationAttemptId = randomUUID()
log("[tool-execute-before] Injecting ULW oracle verification attempt", {
sessionID: input.sessionID,
callID: input.callID,
verificationAttemptId,
loopSessionID: loopState.session_id,
})
writeState(ctx.directory, {
...loopState,
verification_attempt_id: verificationAttemptId,

View File

@@ -19,27 +19,6 @@ describe("tool.execute.before ultrawork oracle verification", () => {
}
}
function createOracleTaskArgs(prompt: string): Record<string, unknown> {
return {
subagent_type: "oracle",
run_in_background: true,
prompt,
}
}
function createSyncTaskMetadata(
args: Record<string, unknown>,
sessionId: string,
): Record<string, unknown> {
return {
prompt: args.prompt,
agent: "oracle",
run_in_background: args.run_in_background,
sessionId,
sync: true,
}
}
test("#given ulw loop is awaiting verification #when oracle task runs #then oracle prompt is enforced and sync", async () => {
const directory = join(tmpdir(), `tool-before-ulw-${Date.now()}`)
mkdirSync(directory, { recursive: true })
@@ -59,7 +38,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
})
const output = { args: createOracleTaskArgs("Check it") }
const output = {
args: {
subagent_type: "oracle",
run_in_background: true,
prompt: "Check it",
} as Record<string, unknown>,
}
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
@@ -79,7 +64,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
})
const output = { args: createOracleTaskArgs("Check it") }
const output = {
args: {
subagent_type: "oracle",
run_in_background: true,
prompt: "Check it",
} as Record<string, unknown>,
}
await handler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, output)
@@ -89,7 +80,7 @@ describe("tool.execute.before ultrawork oracle verification", () => {
rmSync(directory, { recursive: true, force: true })
})
test("#given ulw loop is awaiting verification #when oracle sync task metadata is persisted #then oracle session id is stored", async () => {
test("#given ulw loop is awaiting verification #when oracle task finishes #then oracle session id is stored", async () => {
const directory = join(tmpdir(), `tool-after-ulw-${Date.now()}`)
mkdirSync(directory, { recursive: true })
writeState(directory, {
@@ -108,44 +99,14 @@ describe("tool.execute.before ultrawork oracle verification", () => {
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteBeforeHandler>[0]["ctx"],
hooks: {} as Parameters<typeof createToolExecuteBeforeHandler>[0]["hooks"],
})
const beforeOutput = { args: createOracleTaskArgs("Check it") }
const beforeOutput = {
args: {
subagent_type: "oracle",
run_in_background: true,
prompt: "Check it",
} as Record<string, unknown>,
}
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, beforeOutput)
const metadataFromSyncTask = createSyncTaskMetadata(beforeOutput.args, "ses-oracle")
const handler = createToolExecuteAfterHandler({
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
})
await handler(
{ tool: "task", sessionID: "ses-main", callID: "call-1" },
{
title: "oracle task",
output: "done",
metadata: metadataFromSyncTask,
},
)
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
clearState(directory)
rmSync(directory, { recursive: true, force: true })
})
test("#given ulw loop is awaiting verification #when oracle metadata prompt is missing #then oracle session fallback is stored", async () => {
const directory = join(tmpdir(), `tool-after-ulw-fallback-${Date.now()}`)
mkdirSync(directory, { recursive: true })
writeState(directory, {
active: true,
iteration: 3,
completion_promise: ULTRAWORK_VERIFICATION_PROMISE,
initial_completion_promise: "DONE",
started_at: new Date().toISOString(),
prompt: "Ship feature",
session_id: "ses-main",
ultrawork: true,
verification_pending: true,
})
const handler = createToolExecuteAfterHandler({
ctx: createCtx(directory) as unknown as Parameters<typeof createToolExecuteAfterHandler>[0]["ctx"],
@@ -159,13 +120,13 @@ describe("tool.execute.before ultrawork oracle verification", () => {
output: "done",
metadata: {
agent: "oracle",
sessionId: "ses-oracle-fallback",
sync: true,
prompt: String(beforeOutput.args.prompt),
sessionId: "ses-oracle",
},
},
)
expect(readState(directory)?.verification_session_id).toBe("ses-oracle-fallback")
expect(readState(directory)?.verification_session_id).toBe("ses-oracle")
clearState(directory)
rmSync(directory, { recursive: true, force: true })
@@ -195,11 +156,23 @@ describe("tool.execute.before ultrawork oracle verification", () => {
hooks: {} as Parameters<typeof createToolExecuteAfterHandler>[0]["hooks"],
})
const firstOutput = { args: createOracleTaskArgs("Check it") }
const firstOutput = {
args: {
subagent_type: "oracle",
run_in_background: true,
prompt: "Check it",
} as Record<string, unknown>,
}
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-1" }, firstOutput)
const firstAttemptId = readState(directory)?.verification_attempt_id
const secondOutput = { args: createOracleTaskArgs("Check it again") }
const secondOutput = {
args: {
subagent_type: "oracle",
run_in_background: true,
prompt: "Check it again",
} as Record<string, unknown>,
}
await beforeHandler({ tool: "task", sessionID: "ses-main", callID: "call-2" }, secondOutput)
const secondAttemptId = readState(directory)?.verification_attempt_id

View File

@@ -110,16 +110,12 @@ function applyResolvedUltraworkOverride(args: {
if (!override.providerID || !override.modelID) return
const targetModel = { providerID: override.providerID, modelID: override.modelID }
const messageId = output.message["id"] as string | undefined
if (isSameModel(output.message.model, targetModel)) {
if (validatedVariant && messageId) {
scheduleDeferredModelOverride(messageId, targetModel, validatedVariant)
log(`[ultrawork-model-override] Persist validated variant for active model: ${override.modelID}`)
return
}
log(`[ultrawork-model-override] Skip override; target model already active: ${override.modelID}`)
return
}
const messageId = output.message["id"] as string | undefined
if (!messageId) {
log("[ultrawork-model-override] No message ID found, falling back to direct mutation")
output.message.model = targetModel

View File

@@ -1,5 +1,4 @@
export const PLUGIN_NAME = "oh-my-opencode"
export const LEGACY_PLUGIN_NAME = "oh-my-openagent"
export const CONFIG_BASENAME = "oh-my-opencode"
export const LOG_FILENAME = "oh-my-opencode.log"
export const CACHE_DIR_NAME = "oh-my-opencode"

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