Merge branch 'dev' into fix/issue-2232

This commit is contained in:
YeonGyu-Kim
2026-03-11 20:20:04 +09:00
committed by GitHub
210 changed files with 7089 additions and 1274 deletions
+92
View File
@@ -0,0 +1,92 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { buildAntiDuplicationSection } from "./dynamic-agent-prompt-builder"
describe("buildAntiDuplicationSection", () => {
it("#given no arguments #when building anti-duplication section #then returns comprehensive rule section", () => {
//#given: no special configuration needed
//#when: building the anti-duplication section
const result = buildAntiDuplicationSection()
//#then: should contain the anti-duplication rule with all key concepts
expect(result).toContain("Anti-Duplication Rule")
expect(result).toContain("CRITICAL")
expect(result).toContain("DO NOT perform the same search yourself")
})
it("#given no arguments #when building #then explicitly forbids manual re-search after delegation", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explicitly list forbidden behaviors
expect(result).toContain("FORBIDDEN")
expect(result).toContain("manually grep/search for the same information")
expect(result).toContain("Re-doing the research")
})
it("#given no arguments #when building #then allows non-overlapping work", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explicitly allow non-overlapping work
expect(result).toContain("ALLOWED")
expect(result).toContain("non-overlapping work")
expect(result).toContain("work that doesn't depend on the delegated research")
})
it("#given no arguments #when building #then includes wait-for-results instructions", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should include instructions for waiting properly
expect(result).toContain("Wait for Results Properly")
expect(result).toContain("End your response")
expect(result).toContain("Wait for the completion notification")
expect(result).toContain("background_output")
})
it("#given no arguments #when building #then explains why this matters", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should explain the purpose
expect(result).toContain("Why This Matters")
expect(result).toContain("Wasted tokens")
expect(result).toContain("Confusion")
expect(result).toContain("Efficiency")
})
it("#given no arguments #when building #then provides code examples", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should include examples
expect(result).toContain("Example")
expect(result).toContain("WRONG")
expect(result).toContain("CORRECT")
expect(result).toContain("task(subagent_type=")
})
it("#given no arguments #when building #then uses proper markdown formatting", () => {
//#given: no special configuration
//#when: building the section
const result = buildAntiDuplicationSection()
//#then: should be wrapped in Anti_Duplication tag
expect(result).toContain("<Anti_Duplication>")
expect(result).toContain("</Anti_Duplication>")
})
})
+118
View File
@@ -0,0 +1,118 @@
import { describe, test, expect } from "bun:test"
import { ATLAS_SYSTEM_PROMPT } from "./default"
import { ATLAS_GPT_SYSTEM_PROMPT } from "./gpt"
import { ATLAS_GEMINI_SYSTEM_PROMPT } from "./gemini"
describe("Atlas prompts auto-continue policy", () => {
test("default variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("gpt variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_GPT_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("gemini variant should forbid asking user for continuation confirmation", () => {
// given
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
// when
const lowerPrompt = prompt.toLowerCase()
// then
expect(lowerPrompt).toContain("auto-continue policy")
expect(lowerPrompt).toContain("never ask the user")
expect(lowerPrompt).toContain("should i continue")
expect(lowerPrompt).toContain("proceed to next task")
expect(lowerPrompt).toContain("approval-style")
expect(lowerPrompt).toContain("auto-continue immediately")
})
test("all variants should require immediate continuation after verification passes", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
const lowerPrompt = prompt.toLowerCase()
expect(lowerPrompt).toMatch(/auto-continue immediately after verification/)
expect(lowerPrompt).toMatch(/immediately delegate next task/)
}
})
test("all variants should define when user interaction is actually needed", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
const lowerPrompt = prompt.toLowerCase()
expect(lowerPrompt).toMatch(/only pause.*truly blocked/)
expect(lowerPrompt).toMatch(/plan needs clarification|blocked by external/)
}
})
})
describe("Atlas prompts plan path consistency", () => {
test("default variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/{plan-name}.yaml")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("gpt variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_GPT_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("gemini variant should use .sisyphus/plans/{plan-name}.md path", () => {
// given
const prompt = ATLAS_GEMINI_SYSTEM_PROMPT
// when / then
expect(prompt).toContain(".sisyphus/plans/{plan-name}.md")
expect(prompt).not.toContain(".sisyphus/tasks/")
})
test("all variants should read plan file after verification", () => {
// given
const prompts = [ATLAS_SYSTEM_PROMPT, ATLAS_GPT_SYSTEM_PROMPT, ATLAS_GEMINI_SYSTEM_PROMPT]
// when / then
for (const prompt of prompts) {
expect(prompt).toMatch(/read[\s\S]*?\.sisyphus\/plans\//)
}
})
})
+27 -4
View File
@@ -100,6 +100,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**If your prompt is under 30 lines, it's TOO SHORT.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
@@ -184,7 +207,7 @@ task(
After EVERY delegation, complete ALL of these steps — no shortcuts:
#### A. Automated Verification
1. \`lsp_diagnostics(filePath=".")\` → ZERO errors at project level
1. 'lsp_diagnostics(filePath=".", extension=".ts")' → ZERO errors across scanned TypeScript files (directory scans are capped at 50 files; not a full-project guarantee)
2. \`bun run build\` or \`bun run typecheck\` → exit code 0
3. \`bun test\` → ALL tests pass
@@ -346,7 +369,7 @@ You are the QA gate. Subagents lie. Verify EVERYTHING.
**After each delegation — BOTH automated AND manual verification are MANDATORY:**
1. \`lsp_diagnostics\` at PROJECT level → ZERO errors
1. 'lsp_diagnostics(filePath=".", extension=".ts")' across scanned TypeScript files → ZERO errors (directory scans are capped at 50 files; not a full-project guarantee)
2. Run build command → exit 0
3. Run test suite → ALL pass
4. **\`Read\` EVERY changed file line by line** → logic matches requirements
@@ -390,14 +413,14 @@ You are the QA gate. Subagents lie. Verify EVERYTHING.
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics after delegation
- Skip scanned-file lsp_diagnostics after delegation (use 'filePath=".", extension=".ts"' for TypeScript projects; directory scans are capped at 50 files)
- Batch multiple tasks in one delegation
- Start fresh session for failures/follow-ups - use \`resume\` instead
**ALWAYS**:
- Include ALL 6 sections in delegation prompts
- Read notepad before every delegation
- Run project-level QA after every delegation
- Run scanned-file QA after every delegation
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Verify with your own tools
+26 -3
View File
@@ -117,6 +117,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**Minimum 30 lines per delegation prompt. Under 30 lines = the subagent WILL fail.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
@@ -361,14 +384,14 @@ Subagents CLAIM "done" when:
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Skip scanned-file lsp_diagnostics (use 'filePath=".", extension=".ts"' for TypeScript projects; directory scans are capped at 50 files)
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
**ALWAYS**:
- Include ALL 6 sections in delegation prompts
- Read notepad before every delegation
- Run project-level QA after every delegation
- Run scanned-file QA after every delegation
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Store and reuse session_id for retries
@@ -392,4 +415,4 @@ This ensures accurate progress tracking. Skip this and you lose visibility into
export function getGeminiAtlasPrompt(): string {
return ATLAS_GEMINI_SYSTEM_PROMPT
}
}
+28 -4
View File
@@ -40,9 +40,10 @@ Implementation tasks are the means. Final Wave approval is the goal.
</scope_and_design_constraints>
<uncertainty_and_ambiguity>
- If a task is ambiguous or underspecified:
- During initial plan analysis, if a task is ambiguous or underspecified:
- Ask 1-3 precise clarifying questions, OR
- State your interpretation explicitly and proceed with the simplest approach.
- Once execution has started, do NOT stop to ask for continuation or approval between steps.
- Never fabricate task details, file paths, or requirements.
- Prefer language like "Based on the plan..." instead of absolute claims.
- When unsure about parallelization, default to sequential execution.
@@ -55,7 +56,7 @@ Implementation tasks are the means. Final Wave approval is the goal.
- Verification (use Bash for tests/build)
- Parallelize independent tool calls when possible.
- After ANY delegation, verify with your own tool calls:
1. \`lsp_diagnostics\` at project level
1. 'lsp_diagnostics(filePath=".", extension=".ts")' across scanned TypeScript files (directory scans are capped at 50 files; not a full-project guarantee)
2. \`Bash\` for build/test commands
3. \`Read\` for changed files
</tool_usage_rules>
@@ -126,6 +127,29 @@ Every \`task()\` prompt MUST include ALL 6 sections:
**Minimum 30 lines per delegation prompt.**
</delegation_system>
<auto_continue>
## AUTO-CONTINUE POLICY (STRICT)
**CRITICAL: NEVER ask the user "should I continue", "proceed to next task", or any approval-style questions between plan steps.**
**You MUST auto-continue immediately after verification passes:**
- After any delegation completes and passes verification → Immediately delegate next task
- Do NOT wait for user input, do NOT ask "should I continue"
- Only pause or ask if you are truly blocked by missing information, an external dependency, or a critical failure
**The only time you ask the user:**
- Plan needs clarification or modification before execution
- Blocked by an external dependency beyond your control
- Critical failure prevents any further progress
**Auto-continue examples:**
- Task A done → Verify → Pass → Immediately start Task B
- Task fails → Retry 3x → Still fails → Document → Move to next independent task
- NEVER: "Should I continue to the next task?"
**This is NOT optional. This is core to your role as orchestrator.**
</auto_continue>
<workflow>
## Step 0: Register Tracking
@@ -364,14 +388,14 @@ Your job is to CATCH THEM. Assume every claim is false until YOU personally veri
- Trust subagent claims without verification
- Use run_in_background=true for task execution
- Send prompts under 30 lines
- Skip project-level lsp_diagnostics
- Skip scanned-file lsp_diagnostics (use 'filePath=".", extension=".ts"' for TypeScript projects; directory scans are capped at 50 files)
- Batch multiple tasks in one delegation
- Start fresh session for failures (use session_id)
**ALWAYS**:
- Include ALL 6 sections in delegation prompts
- Read notepad before every delegation
- Run project-level QA after every delegation
- Run scanned-file QA after every delegation
- Pass inherited wisdom to every subagent
- Parallelize independent tasks
- Store and reuse session_id for retries
+3 -1
View File
@@ -12,6 +12,7 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
fetchAvailableModels,
@@ -41,6 +42,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
}
/**
@@ -82,7 +84,7 @@ export async function createBuiltinAgents(
)
// IMPORTANT: Do NOT call OpenCode client APIs during plugin initialization.
// This function is called from config handler, and calling client API causes deadlock.
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
// See: https://github.com/code-yeongyu/oh-my-openagent/issues/1301
const availableModels = await fetchAvailableModels(undefined, {
connectedProviders: mergedConnectedProviders.length > 0 ? mergedConnectedProviders : undefined,
})
@@ -50,6 +50,7 @@ export function collectPendingBuiltinAgents(input: {
if (agentName === "sisyphus") continue
if (agentName === "hephaestus") continue
if (agentName === "atlas") continue
if (agentName === "sisyphus-junior") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, test } from "bun:test"
import { createSisyphusAgent } from "./sisyphus"
import { createHephaestusAgent } from "./hephaestus"
import { buildSisyphusJuniorPrompt } from "./sisyphus-junior/agent"
import {
buildAntiDuplicationSection,
buildExploreSection,
type AvailableAgent,
} from "./dynamic-agent-prompt-builder"
const exploreAgent = {
name: "explore",
description: "Contextual grep specialist",
metadata: {
category: "advisor",
cost: "FREE",
promptAlias: "Explore",
triggers: [],
useWhen: ["Multiple search angles needed"],
avoidWhen: ["Single keyword search is enough"],
},
} satisfies AvailableAgent
describe("delegation trust prompt rules", () => {
test("buildAntiDuplicationSection explains overlap is forbidden", () => {
// given
const section = buildAntiDuplicationSection()
// when / then
expect(section).toContain("DO NOT perform the same search yourself")
expect(section).toContain("non-overlapping work")
expect(section).toContain("End your response")
})
test("buildExploreSection includes delegation trust rule", () => {
// given
const agents = [exploreAgent]
// when
const section = buildExploreSection(agents)
// then
expect(section).toContain("Delegation Trust Rule")
expect(section).toContain("do **not** manually perform that same search yourself")
})
test("Sisyphus prompt forbids duplicate delegated exploration", () => {
// given
const agent = createSisyphusAgent("anthropic/claude-sonnet-4-6", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("Continue only with non-overlapping work")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.2", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.4", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Hephaestus GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
// given
const agent = createHephaestusAgent("openai/gpt-5.3-codex", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("Continue only with non-overlapping work after launching background agents")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.2", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const agent = createSisyphusAgent("openai/gpt-5.4", [exploreAgent])
// when
const prompt = agent.prompt
// then
expect(prompt).toContain("do only non-overlapping work simultaneously")
expect(prompt).toContain("Continue only with non-overlapping work")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT-5.4 prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.4", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior GPT-5.3 Codex prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("openai/gpt-5.3-codex", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
test("Sisyphus-Junior Gemini prompt forbids duplicate delegated exploration", () => {
// given
const prompt = buildSisyphusJuniorPrompt("google/gemini-3.1-pro", false)
// when / then
expect(prompt).toContain("continue only with non-overlapping work while they search")
expect(prompt).toContain("DO NOT perform the same search yourself")
})
})
+70 -1
View File
@@ -116,7 +116,9 @@ export function buildExploreSection(agents: AvailableAgent[]): string {
return `### Explore Agent = Contextual Grep
Use it as a **peer tool**, not a fallback. Fire liberally.
Use it as a **peer tool**, not a fallback. Fire liberally for discovery, not for files you already know.
**Delegation Trust Rule:** Once you fire an explore agent for a search, do **not** manually perform that same search yourself. Use direct tools only for non-overlapping work or when you intentionally skipped delegation.
**Use Direct Tools when:**
${avoidWhen.map((w) => `- ${w}`).join("\n")}
@@ -335,6 +337,7 @@ export function buildAntiPatternsSection(): string {
"- **Search**: Firing agents for single-line typos or obvious syntax errors",
"- **Debugging**: Shotgun debugging, random changes",
"- **Background Tasks**: Polling `background_output` on running tasks — end response and wait for notification",
"- **Delegation Duplication**: Delegating exploration to explore/librarian and then manually doing the same search yourself",
"- **Oracle**: Delivering answer without collecting Oracle results",
]
@@ -343,6 +346,23 @@ export function buildAntiPatternsSection(): string {
${patterns.join("\n")}`
}
export function buildToolCallFormatSection(): string {
return `## Tool Call Format (CRITICAL)
**ALWAYS use the native tool calling mechanism. NEVER output tool calls as text.**
When you need to call a tool:
1. Use the tool call interface provided by the system
2. Do NOT write tool calls as plain text like \`assistant to=functions.XXX\`
3. Do NOT output JSON directly in your text response
4. The system handles tool call formatting automatically
**CORRECT**: Invoke the tool through the tool call interface
**WRONG**: Writing \`assistant to=functions.todowrite\` or \`json\n{...}\` as text
Your tool calls are processed automatically. Just invoke the tool - do not format the call yourself.`
}
export function buildNonClaudePlannerSection(model: string): string {
const isNonClaude = !model.toLowerCase().includes('claude')
if (!isNonClaude) return ""
@@ -453,3 +473,52 @@ export function buildUltraworkSection(
return lines.join("\n")
}
// Anti-duplication section for agent prompts
export function buildAntiDuplicationSection(): string {
return `<Anti_Duplication>
## Anti-Duplication Rule (CRITICAL)
Once you delegate exploration to explore/librarian agents, **DO NOT perform the same search yourself**.
### What this means:
**FORBIDDEN:**
- After firing explore/librarian, manually grep/search for the same information
- Re-doing the research the agents were just tasked with
- "Just quickly checking" the same files the background agents are checking
**ALLOWED:**
- Continue with **non-overlapping work** — work that doesn't depend on the delegated research
- Work on unrelated parts of the codebase
- Preparation work (e.g., setting up files, configs) that can proceed independently
### Wait for Results Properly:
When you need the delegated results but they're not ready:
1. **End your response** — do NOT continue with work that depends on those results
2. **Wait for the completion notification** — the system will trigger your next turn
3. **Then** collect results via \`background_output(task_id="...")\`
4. **Do NOT** impatiently re-search the same topics while waiting
### Why This Matters:
- **Wasted tokens**: Duplicate exploration wastes your context budget
- **Confusion**: You might contradict the agent's findings
- **Efficiency**: The whole point of delegation is parallel throughput
### Example:
\`\`\`typescript
// WRONG: After delegating, re-doing the search
task(subagent_type="explore", run_in_background=true, ...)
// Then immediately grep for the same thing yourself — FORBIDDEN
// CORRECT: Continue non-overlapping work
task(subagent_type="explore", run_in_background=true, ...)
// Work on a different, unrelated file while they search
// End your response and wait for the notification
\`\`\`
</Anti_Duplication>`
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Creates OmO-specific environment context (timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
* See: https://github.com/code-yeongyu/oh-my-openagent/issues/379
*/
export function createEnvContext(): string {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+8 -3
View File
@@ -17,6 +17,8 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildToolCallFormatSection,
buildAntiDuplicationSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
const MODE: AgentMode = "all";
@@ -127,7 +129,7 @@ export function buildHephaestusPrompt(
const hardBlocks = buildHardBlocksSection();
const antiPatterns = buildAntiPatternsSection();
const todoDiscipline = buildTodoDisciplineSection(useTaskSystem);
const toolCallFormat = buildToolCallFormatSection();
return `You are Hephaestus, an autonomous deep worker for software engineering.
## Identity
@@ -155,7 +157,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
@@ -166,6 +168,7 @@ ${hardBlocks}
${antiPatterns}
${toolCallFormat}
## Phase 0 - Intent Gate (EVERY task)
${keyTriggers}
@@ -290,11 +293,13 @@ Prompt structure for each agent:
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+5 -2
View File
@@ -16,6 +16,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
} from "../dynamic-agent-prompt-builder";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
@@ -115,7 +116,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
- User asks "did you do X?" and you didn't → Acknowledge briefly, DO X immediately
- User asks a question implying work → Answer briefly, DO the implied work in the same turn
- You wrote a plan in your response → EXECUTE the plan before ending turn — plans are starting lines, not finish lines
@@ -241,11 +242,13 @@ Prompt structure for each agent:
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually: \`background_cancel(taskId="bg_explore_xxx")\`, \`background_cancel(taskId="bg_librarian_xxx")\`
- **NEVER use \`background_cancel(all=true)\`** — it kills tasks whose results you haven't collected yet
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when you have enough context, the same information keeps appearing, 2 search iterations yielded nothing new, or a direct answer was found. Do not over-explore.
+5 -2
View File
@@ -16,6 +16,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
} from "../dynamic-agent-prompt-builder";
function buildTodoDisciplineSection(useTaskSystem: boolean): string {
@@ -109,7 +110,7 @@ Asking the user is the LAST resort after exhausting creative alternatives.
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian in background IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian in background IMMEDIATELY — continue only with non-overlapping work while they search
## Hard Constraints
@@ -194,11 +195,13 @@ task(subagent_type="librarian", run_in_background=true, load_skills=[], descript
- Fire 2-5 explore agents in parallel for any non-trivial codebase question
- Parallelize independent file reads — don't read files one at a time
- NEVER use \`run_in_background=false\` for explore/librarian
- Continue your work immediately after launching background agents
- Continue only with non-overlapping work after launching background agents
- Collect results with \`background_output(task_id="...")\` when needed
- BEFORE final answer, cancel DISPOSABLE tasks individually
- **NEVER use \`background_cancel(all=true)\`**
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+1
View File
@@ -2,3 +2,4 @@ export * from "./types"
export { createBuiltinAgents } from "./builtin-agents"
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
export type { PrometheusPromptSource } from "./prometheus"
export { createSisyphusJuniorAgentWithOverrides, SISYPHUS_JUNIOR_DEFAULTS } from "./sisyphus-junior"
+3
View File
@@ -8,6 +8,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildDefaultSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -23,6 +24,8 @@ Sisyphus-Junior - Focused executor from OhMyOpenCode.
Execute tasks directly.
</Role>
${buildAntiDuplicationSection()}
${todoDiscipline}
<Verification>
+5 -2
View File
@@ -9,6 +9,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGeminiSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -58,7 +59,7 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -77,13 +78,15 @@ Before responding, ask yourself: What tools do I need to call? What am I assumin
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
- **DO NOT SKIP tool calls because you think you already know the answer. You DON'T.**
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -7,6 +7,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGpt53CodexSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -40,7 +41,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -58,12 +59,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -10,6 +10,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri";
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder";
export function buildGpt54SisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -43,7 +44,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -62,12 +63,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+5 -2
View File
@@ -8,6 +8,7 @@
*/
import { resolvePromptAppend } from "../builtin-agents/resolve-file-uri"
import { buildAntiDuplicationSection } from "../dynamic-agent-prompt-builder"
export function buildGptSisyphusJuniorPrompt(
useTaskSystem: boolean,
@@ -41,7 +42,7 @@ When blocked: try a different approach → decompose the problem → challenge a
- Run verification (lint, tests, build) WITHOUT asking
- Make decisions. Course-correct only on CONCRETE failure
- Note assumptions in final message, not as questions mid-work
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — keep working while they search
- Need context? Fire explore/librarian via call_omo_agent IMMEDIATELY — continue only with non-overlapping work while they search
## Scope Discipline
@@ -59,12 +60,14 @@ When blocked: try a different approach → decompose the problem → challenge a
<tool_usage_rules>
- Parallelize independent tool calls: multiple file reads, grep searches, agent fires — all at once
- Explore/Librarian via call_omo_agent = background research. Fire them and keep working
- Explore/Librarian via call_omo_agent = background research. Fire them and continue only with non-overlapping work
- After any file edit: restate what changed, where, and what validation follows
- Prefer tools over guessing whenever you need specific data (files, configs, patterns)
- ALWAYS use tools over internal knowledge for file contents, project state, and verification
</tool_usage_rules>
${buildAntiDuplicationSection()}
${taskDiscipline}
## Progress Updates
+7 -3
View File
@@ -37,6 +37,7 @@ import {
buildAntiPatternsSection,
buildParallelDelegationSection,
buildNonClaudePlannerSection,
buildAntiDuplicationSection,
categorizeTools,
} from "./dynamic-agent-prompt-builder";
@@ -225,19 +226,22 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue working immediately. System notifies on completion — collect with background_output then.
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
// WRONG: Sequential or blocking
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
\`\`\`
### Background Result Collection:
1. Launch parallel agents \u2192 receive task_ids
2. Continue immediate work
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work \u2192 do it now
- Otherwise \u2192 **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on each task completion — then call \`background_output(task_id="...")\`
4. Need results not yet ready? **End your response.** The notification will trigger your next turn.
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+9 -4
View File
@@ -21,6 +21,7 @@ import {
buildAntiPatternsSection,
buildParallelDelegationSection,
buildNonClaudePlannerSection,
buildAntiDuplicationSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
@@ -319,7 +320,7 @@ task(subagent_type="explore", run_in_background=true, load_skills=[], descriptio
// Reference Grep (external)
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find JWT security docs", prompt="I'm implementing JWT auth and need current security best practices to choose token storage (httpOnly cookies vs localStorage) and set expiration policy. Find: OWASP auth guidelines, recommended token lifetimes, refresh token rotation strategies, common JWT vulnerabilities. Skip 'what is JWT' tutorials — production security guidance only.")
task(subagent_type="librarian", run_in_background=true, load_skills=[], description="Find Express auth patterns", prompt="I'm building Express auth middleware and need production-quality patterns to structure my middleware chain. Find how established Express apps (1000+ stars) handle: middleware ordering, token refresh, role-based access control, auth error propagation. Skip basic tutorials — I need battle-tested patterns with proper error handling.")
// Continue working immediately. System notifies on completion — collect with background_output then.
// Continue only with non-overlapping work. If none exists, end your response and wait for completion.
// WRONG: Sequential or blocking
result = task(..., run_in_background=false) // Never wait synchronously for explore/librarian
@@ -327,11 +328,15 @@ result = task(..., run_in_background=false) // Never wait synchronously for exp
### Background Result Collection:
1. Launch parallel agents → receive task_ids
2. Continue immediate work
3. System sends \`<system-reminder>\` on each task completion — then call \`background_output(task_id="...")\`
4. Need results not yet ready? **End your response.** The notification will trigger your next turn.
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work → do it now
- Otherwise → **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on completion → triggers your next turn
4. Collect via \`background_output(task_id="...")\`
5. Cleanup: Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
### Search Stop Conditions
STOP searching when:
+9 -4
View File
@@ -37,6 +37,7 @@ import {
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
buildAntiDuplicationSection,
buildNonClaudePlannerSection,
categorizeTools,
} from "../dynamic-agent-prompt-builder";
@@ -233,7 +234,7 @@ ${librarianSection}
<tool_method>
- Fire 2-5 explore/librarian agents in parallel for any non-trivial codebase question.
- Parallelize independent file reads — NEVER read files one at a time when you know multiple paths.
- When delegating AND doing direct work: do both simultaneously.
- When delegating AND doing direct work: do only non-overlapping work simultaneously.
</tool_method>
Explore and Librarian agents are background grep — always \`run_in_background=true\`, always parallel.
@@ -246,11 +247,15 @@ Each agent prompt should include:
Background result collection:
1. Launch parallel agents → receive task_ids
2. Continue immediate work
3. System sends \`<system-reminder>\` on completion → call \`background_output(task_id="...")\`
4. If results aren't ready: end your response. The notification triggers your next turn.
2. Continue only with non-overlapping work
- If you have DIFFERENT independent work → do it now
- Otherwise → **END YOUR RESPONSE.**
3. System sends \`<system-reminder>\` on completion → triggers your next turn
4. Collect via \`background_output(task_id="...")\`
5. Cancel disposable tasks individually via \`background_cancel(taskId="...")\`
${buildAntiDuplicationSection()}
Stop searching when: you have enough context, same info repeating, 2 iterations with no new data, or direct answer found.
</explore>`;
+2 -1
View File
@@ -113,7 +113,8 @@ export type BuiltinAgentName =
| "multimodal-looker"
| "metis"
| "momus"
| "atlas";
| "atlas"
| "sisyphus-junior";
export type OverridableAgentName = "build" | BuiltinAgentName;
@@ -2,7 +2,7 @@
exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK for all agents and categories when no providers 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -63,7 +63,7 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
exports[`generateModelConfig single native provider uses Claude models when only Claude is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -125,7 +125,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
exports[`generateModelConfig single native provider uses Claude models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -188,21 +188,23 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
exports[`generateModelConfig single native provider uses OpenAI models when only OpenAI is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "opencode/gpt-5-nano",
"model": "openai/gpt-5.4",
"variant": "medium",
},
"hephaestus": {
"model": "openai/gpt-5.3-codex",
"variant": "medium",
},
"librarian": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "medium",
},
"metis": {
"model": "openai/gpt-5.4",
@@ -230,12 +232,17 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
},
},
"categories": {
"artistry": {
"model": "openai/gpt-5.4",
"variant": "xhigh",
},
"deep": {
"model": "openai/gpt-5.3-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.3-codex",
"variant": "low",
},
"ultrabrain": {
"model": "openai/gpt-5.3-codex",
@@ -250,10 +257,12 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"variant": "medium",
},
"visual-engineering": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "high",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "medium",
},
},
}
@@ -261,21 +270,23 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
exports[`generateModelConfig single native provider uses OpenAI models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "opencode/gpt-5-nano",
"model": "openai/gpt-5.4",
"variant": "medium",
},
"hephaestus": {
"model": "openai/gpt-5.3-codex",
"variant": "medium",
},
"librarian": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "medium",
},
"metis": {
"model": "openai/gpt-5.4",
@@ -303,12 +314,17 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
},
},
"categories": {
"artistry": {
"model": "openai/gpt-5.4",
"variant": "xhigh",
},
"deep": {
"model": "openai/gpt-5.3-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.3-codex",
"variant": "low",
},
"ultrabrain": {
"model": "openai/gpt-5.3-codex",
@@ -323,10 +339,12 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"variant": "medium",
},
"visual-engineering": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "high",
},
"writing": {
"model": "opencode/glm-4.7-free",
"model": "openai/gpt-5.4",
"variant": "medium",
},
},
}
@@ -334,7 +352,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
exports[`generateModelConfig single native provider uses Gemini models when only Gemini is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3.1-pro-preview",
@@ -395,7 +413,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
exports[`generateModelConfig single native provider uses Gemini models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "google/gemini-3.1-pro-preview",
@@ -456,7 +474,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
exports[`generateModelConfig all native providers uses preferred models from fallback chains when all natives available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -531,7 +549,7 @@ exports[`generateModelConfig all native providers uses preferred models from fal
exports[`generateModelConfig all native providers uses preferred models with isMax20 flag when all natives available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -607,7 +625,7 @@ exports[`generateModelConfig all native providers uses preferred models with isM
exports[`generateModelConfig fallback providers uses OpenCode Zen models when only OpenCode Zen is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/claude-sonnet-4-5",
@@ -682,7 +700,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
exports[`generateModelConfig fallback providers uses OpenCode Zen models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/claude-sonnet-4-5",
@@ -758,7 +776,7 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
exports[`generateModelConfig fallback providers uses GitHub Copilot models when only Copilot is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -824,7 +842,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
exports[`generateModelConfig fallback providers uses GitHub Copilot models with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -891,7 +909,7 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
exports[`generateModelConfig fallback providers uses ZAI model for librarian when only ZAI is available 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -946,7 +964,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
exports[`generateModelConfig fallback providers uses ZAI model for librarian with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/glm-4.7-free",
@@ -1001,7 +1019,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen combination 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1076,7 +1094,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot combination 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -1151,7 +1169,7 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combination (librarian uses ZAI) 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1212,7 +1230,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combination (explore uses Gemini) 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1278,7 +1296,7 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
exports[`generateModelConfig mixed provider scenarios uses all fallback providers together 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
@@ -1353,7 +1371,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
exports[`generateModelConfig mixed provider scenarios uses all providers together 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
@@ -1428,7 +1446,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
exports[`generateModelConfig mixed provider scenarios uses all providers with isMax20 flag 1`] = `
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json",
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
+1 -1
View File
@@ -122,7 +122,7 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`,
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`,
)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
+4
View File
@@ -69,6 +69,7 @@ program
.passThroughOptions()
.description("Run opencode with todo/background task completion enforcement")
.option("-a, --agent <name>", "Agent to use (default: from CLI/env/config, fallback: Sisyphus)")
.option("-m, --model <provider/model>", "Model override (e.g., anthropic/claude-sonnet-4)")
.option("-d, --directory <path>", "Working directory")
.option("-p, --port <port>", "Server port (attaches if port already in use)", parseInt)
.option("--attach <url>", "Attach to existing opencode server URL")
@@ -86,6 +87,8 @@ Examples:
$ bunx oh-my-opencode run --json "Fix the bug" | jq .sessionId
$ bunx oh-my-opencode run --on-complete "notify-send Done" "Fix the bug"
$ bunx oh-my-opencode run --session-id ses_abc123 "Continue the work"
$ bunx oh-my-opencode run --model anthropic/claude-sonnet-4 "Fix the bug"
$ bunx oh-my-opencode run --agent Sisyphus --model openai/gpt-5.4 "Implement feature X"
Agent resolution order:
1) --agent flag
@@ -108,6 +111,7 @@ Unlike 'opencode run', this command waits until:
const runOptions: RunOptions = {
message,
agent: options.agent,
model: options.model,
directory: options.directory,
port: options.port,
attach: options.attach,
+1 -1
View File
@@ -207,7 +207,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config)
// #then Sisyphus is omitted (requires all fallback providers)
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json")
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).sisyphus).toBeUndefined()
})
@@ -0,0 +1,43 @@
import { beforeEach, afterEach, describe, expect, it, spyOn } from "bun:test"
import * as fs from "node:fs"
import * as dataPath from "../../shared/data-path"
import * as logger from "../../shared/logger"
import * as spawnHelpers from "../../shared/spawn-with-windows-hide"
import { runBunInstallWithDetails } from "./bun-install"
describe("runBunInstallWithDetails", () => {
let getOpenCodeCacheDirSpy: ReturnType<typeof spyOn>
let logSpy: ReturnType<typeof spyOn>
let spawnWithWindowsHideSpy: ReturnType<typeof spyOn>
let existsSyncSpy: ReturnType<typeof spyOn>
beforeEach(() => {
getOpenCodeCacheDirSpy = spyOn(dataPath, "getOpenCodeCacheDir").mockReturnValue("/tmp/opencode-cache")
logSpy = spyOn(logger, "log").mockImplementation(() => {})
spawnWithWindowsHideSpy = spyOn(spawnHelpers, "spawnWithWindowsHide").mockReturnValue({
exited: Promise.resolve(0),
exitCode: 0,
kill: () => {},
} as ReturnType<typeof spawnHelpers.spawnWithWindowsHide>)
existsSyncSpy = spyOn(fs, "existsSync").mockReturnValue(true)
})
afterEach(() => {
getOpenCodeCacheDirSpy.mockRestore()
logSpy.mockRestore()
spawnWithWindowsHideSpy.mockRestore()
existsSyncSpy.mockRestore()
})
it("runs bun install in the OpenCode cache directory", async () => {
const result = await runBunInstallWithDetails()
expect(result).toEqual({ success: true })
expect(getOpenCodeCacheDirSpy).toHaveBeenCalledTimes(1)
expect(spawnWithWindowsHideSpy).toHaveBeenCalledWith(["bun", "install"], {
cwd: "/tmp/opencode-cache",
stdout: "inherit",
stderr: "inherit",
})
})
})
+17 -5
View File
@@ -1,4 +1,6 @@
import { getConfigDir } from "./config-context"
import { existsSync } from "node:fs"
import { getOpenCodeCacheDir } from "../../shared/data-path"
import { log } from "../../shared/logger"
import { spawnWithWindowsHide } from "../../shared/spawn-with-windows-hide"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
@@ -16,9 +18,19 @@ export async function runBunInstall(): Promise<boolean> {
}
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
const cacheDir = getOpenCodeCacheDir()
const packageJsonPath = `${cacheDir}/package.json`
if (!existsSync(packageJsonPath)) {
return {
success: false,
error: `Workspace not initialized: ${packageJsonPath} not found. OpenCode should create this on first run.`,
}
}
try {
const proc = spawnWithWindowsHide(["bun", "install"], {
cwd: getConfigDir(),
cwd: cacheDir,
stdout: "inherit",
stderr: "inherit",
})
@@ -34,13 +46,13 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
if (result === "timeout") {
try {
proc.kill()
} catch {
/* intentionally empty - process may have already exited */
} catch (err) {
log("[cli/install] Failed to kill timed out bun install process:", err)
}
return {
success: false,
timedOut: true,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ${getConfigDir()} && bun i`,
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd "${cacheDir}" && bun i`,
}
}
@@ -1,8 +1,111 @@
import { describe, expect, it } from "bun:test"
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { getSuggestedInstallTag } from "./system-loaded-version"
import { PACKAGE_NAME } from "../constants"
const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test"
const { getLoadedPluginVersion, getSuggestedInstallTag }: typeof import("./system-loaded-version") =
await import(systemLoadedVersionModulePath)
const originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const originalXdgCacheHome = process.env.XDG_CACHE_HOME
const temporaryDirectories: string[] = []
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix))
temporaryDirectories.push(directory)
return directory
}
function writeJson(filePath: string, value: Record<string, string | Record<string, string>>): void {
mkdirSync(dirname(filePath), { recursive: true })
writeFileSync(filePath, JSON.stringify(value), "utf-8")
}
afterEach(() => {
if (originalOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
}
if (originalXdgCacheHome === undefined) {
delete process.env.XDG_CACHE_HOME
} else {
process.env.XDG_CACHE_HOME = originalXdgCacheHome
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
describe("system loaded version", () => {
describe("getLoadedPluginVersion", () => {
it("prefers the config directory when both installs exist", () => {
//#given
const configDir = createTemporaryDirectory("omo-config-")
const cacheHome = createTemporaryDirectory("omo-cache-")
const cacheDir = join(cacheHome, "opencode")
process.env.OPENCODE_CONFIG_DIR = configDir
process.env.XDG_CACHE_HOME = cacheHome
writeJson(join(configDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "1.2.3" },
})
writeJson(join(configDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "1.2.3",
})
writeJson(join(cacheDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "9.9.9" },
})
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "9.9.9",
})
//#when
const loadedVersion = getLoadedPluginVersion()
//#then
expect(loadedVersion.cacheDir).toBe(configDir)
expect(loadedVersion.cachePackagePath).toBe(join(configDir, "package.json"))
expect(loadedVersion.installedPackagePath).toBe(join(configDir, "node_modules", PACKAGE_NAME, "package.json"))
expect(loadedVersion.expectedVersion).toBe("1.2.3")
expect(loadedVersion.loadedVersion).toBe("1.2.3")
})
it("falls back to the cache directory for legacy installs", () => {
//#given
const configDir = createTemporaryDirectory("omo-config-")
const cacheHome = createTemporaryDirectory("omo-cache-")
const cacheDir = join(cacheHome, "opencode")
process.env.OPENCODE_CONFIG_DIR = configDir
process.env.XDG_CACHE_HOME = cacheHome
writeJson(join(cacheDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "2.3.4" },
})
writeJson(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "2.3.4",
})
//#when
const loadedVersion = getLoadedPluginVersion()
//#then
expect(loadedVersion.cacheDir).toBe(cacheDir)
expect(loadedVersion.cachePackagePath).toBe(join(cacheDir, "package.json"))
expect(loadedVersion.installedPackagePath).toBe(join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"))
expect(loadedVersion.expectedVersion).toBe("2.3.4")
expect(loadedVersion.loadedVersion).toBe("2.3.4")
})
})
describe("getSuggestedInstallTag", () => {
it("returns prerelease channel when current version is prerelease", () => {
//#given
+19 -4
View File
@@ -5,7 +5,7 @@ import { join } from "node:path"
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
import { extractChannel } from "../../../hooks/auto-update-checker"
import { PACKAGE_NAME } from "../constants"
import { getOpenCodeCacheDir, parseJsonc } from "../../../shared"
import { getOpenCodeCacheDir, getOpenCodeConfigPaths, parseJsonc } from "../../../shared"
interface PackageJsonShape {
version?: string
@@ -54,9 +54,24 @@ function normalizeVersion(value: string | undefined): string | null {
}
export function getLoadedPluginVersion(): LoadedVersionInfo {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = resolveOpenCodeCacheDir()
const cachePackagePath = join(cacheDir, "package.json")
const installedPackagePath = join(cacheDir, "node_modules", PACKAGE_NAME, "package.json")
const candidates = [
{
cacheDir: configPaths.configDir,
cachePackagePath: configPaths.packageJson,
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
},
{
cacheDir,
cachePackagePath: join(cacheDir, "package.json"),
installedPackagePath: join(cacheDir, "node_modules", PACKAGE_NAME, "package.json"),
},
]
const selectedCandidate = candidates.find((candidate) => existsSync(candidate.installedPackagePath)) ?? candidates[0]
const { cacheDir: selectedDir, cachePackagePath, installedPackagePath } = selectedCandidate
const cachePackage = readPackageJson(cachePackagePath)
const installedPackage = readPackageJson(installedPackagePath)
@@ -65,7 +80,7 @@ export function getLoadedPluginVersion(): LoadedVersionInfo {
const loadedVersion = normalizeVersion(installedPackage?.version)
return {
cacheDir,
cacheDir: selectedDir,
cachePackagePath,
installedPackagePath,
expectedVersion,
+5 -4
View File
@@ -344,15 +344,16 @@ describe("generateModelConfig", () => {
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
})
test("explore uses gpt-5-nano when only OpenAI available", () => {
test("explore uses OpenAI model when only OpenAI available", () => {
// #given only OpenAI is available
const config = createConfig({ hasOpenAI: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use gpt-5-nano (fallback)
expect(result.agents?.explore?.model).toBe("opencode/gpt-5-nano")
// #then explore should use native OpenAI model
expect(result.agents?.explore?.model).toBe("openai/gpt-5.4")
expect(result.agents?.explore?.variant).toBe("medium")
})
test("explore uses gpt-5-mini when only Copilot available", () => {
@@ -516,7 +517,7 @@ describe("generateModelConfig", () => {
// #then should include correct schema URL
expect(result.$schema).toBe(
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json"
"https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
)
})
})
+7 -2
View File
@@ -5,6 +5,7 @@ import {
import type { InstallConfig } from "./types"
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig } from "./model-fallback-types"
import { applyOpenAiOnlyModelCatalog, isOpenAiOnlyAvailability } from "./openai-only-model-catalog"
import { toProviderAvailability } from "./provider-availability"
import {
getSisyphusFallbackChain,
@@ -19,7 +20,7 @@ export type { GeneratedOmoConfig } from "./model-fallback-types"
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/dev/assets/oh-my-opencode.schema.json"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json"
@@ -122,11 +123,15 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
}
}
return {
const generatedConfig: GeneratedOmoConfig = {
$schema: SCHEMA_URL,
agents,
categories,
}
return isOpenAiOnlyAvailability(avail)
? applyOpenAiOnlyModelCatalog(generatedConfig)
: generatedConfig
}
export function shouldShowChatGPTOnlyWarning(config: InstallConfig): boolean {
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test"
import { generateModelConfig } from "./model-fallback"
import type { InstallConfig } from "./types"
function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
return {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
...overrides,
}
}
describe("generateModelConfig OpenAI-only model catalog", () => {
test("fills remaining OpenAI-only agent gaps with OpenAI models", () => {
// #given
const config = createConfig({ hasOpenAI: true })
// #when
const result = generateModelConfig(config)
// #then
expect(result.agents?.explore).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
expect(result.agents?.librarian).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
})
test("fills remaining OpenAI-only category gaps with OpenAI models", () => {
// #given
const config = createConfig({ hasOpenAI: true })
// #when
const result = generateModelConfig(config)
// #then
expect(result.categories?.artistry).toEqual({ model: "openai/gpt-5.4", variant: "xhigh" })
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.3-codex", variant: "low" })
expect(result.categories?.["visual-engineering"]).toEqual({ model: "openai/gpt-5.4", variant: "high" })
expect(result.categories?.writing).toEqual({ model: "openai/gpt-5.4", variant: "medium" })
})
})
+39
View File
@@ -0,0 +1,39 @@
import type { AgentConfig, CategoryConfig, GeneratedOmoConfig, ProviderAvailability } from "./model-fallback-types"
const OPENAI_ONLY_AGENT_OVERRIDES: Record<string, AgentConfig> = {
explore: { model: "openai/gpt-5.4", variant: "medium" },
librarian: { model: "openai/gpt-5.4", variant: "medium" },
}
const OPENAI_ONLY_CATEGORY_OVERRIDES: Record<string, CategoryConfig> = {
artistry: { model: "openai/gpt-5.4", variant: "xhigh" },
quick: { model: "openai/gpt-5.3-codex", variant: "low" },
"visual-engineering": { model: "openai/gpt-5.4", variant: "high" },
writing: { model: "openai/gpt-5.4", variant: "medium" },
}
export function isOpenAiOnlyAvailability(availability: ProviderAvailability): boolean {
return (
availability.native.openai &&
!availability.native.claude &&
!availability.native.gemini &&
!availability.opencodeZen &&
!availability.copilot &&
!availability.zai &&
!availability.kimiForCoding
)
}
export function applyOpenAiOnlyModelCatalog(config: GeneratedOmoConfig): GeneratedOmoConfig {
return {
...config,
agents: {
...config.agents,
...OPENAI_ONLY_AGENT_OVERRIDES,
},
categories: {
...config.categories,
...OPENAI_ONLY_CATEGORY_OVERRIDES,
},
}
}
+1
View File
@@ -1,5 +1,6 @@
export { run } from "./runner"
export { resolveRunAgent } from "./agent-resolver"
export { resolveRunModel } from "./model-resolver"
export { createServerConnection } from "./server-connection"
export { resolveSession } from "./session-resolver"
export { createJsonOutputManager } from "./json-output"
+83
View File
@@ -0,0 +1,83 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { resolveRunModel } from "./model-resolver"
describe("resolveRunModel", () => {
it("given no model string, when resolved, then returns undefined", () => {
// given
const modelString = undefined
// when
const result = resolveRunModel(modelString)
// then
expect(result).toBeUndefined()
})
it("given empty string, when resolved, then throws Error", () => {
// given
const modelString = ""
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given valid 'anthropic/claude-sonnet-4', when resolved, then returns correct object", () => {
// given
const modelString = "anthropic/claude-sonnet-4"
// when
const result = resolveRunModel(modelString)
// then
expect(result).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4" })
})
it("given nested slashes 'openai/gpt-5.3/preview', when resolved, then modelID is 'gpt-5.3/preview'", () => {
// given
const modelString = "openai/gpt-5.3/preview"
// when
const result = resolveRunModel(modelString)
// then
expect(result).toEqual({ providerID: "openai", modelID: "gpt-5.3/preview" })
})
it("given no slash 'claude-sonnet-4', when resolved, then throws Error", () => {
// given
const modelString = "claude-sonnet-4"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given empty provider '/claude-sonnet-4', when resolved, then throws Error", () => {
// given
const modelString = "/claude-sonnet-4"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
it("given trailing slash 'anthropic/', when resolved, then throws Error", () => {
// given
const modelString = "anthropic/"
// when
const resolve = () => resolveRunModel(modelString)
// then
expect(resolve).toThrow()
})
})
+29
View File
@@ -0,0 +1,29 @@
export function resolveRunModel(
modelString?: string
): { providerID: string; modelID: string } | undefined {
if (modelString === undefined) {
return undefined
}
const trimmed = modelString.trim()
if (trimmed.length === 0) {
throw new Error("Model string cannot be empty")
}
const parts = trimmed.split("/")
if (parts.length < 2) {
throw new Error("Model string must be in 'provider/model' format")
}
const providerID = parts[0]
if (providerID.length === 0) {
throw new Error("Provider cannot be empty")
}
const modelID = parts.slice(1).join("/")
if (modelID.length === 0) {
throw new Error("Model ID cannot be empty")
}
return { providerID, modelID }
}
+24 -35
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, mock, spyOn } from "bun:test"
import { afterEach, beforeEach, describe, it, expect, mock, spyOn } from "bun:test"
import type { RunContext, Todo, ChildSession, SessionStatus } from "./types"
import { createEventState } from "./events"
import { pollForCompletion } from "./poll-for-completion"
@@ -30,11 +30,26 @@ const createMockContext = (overrides: {
}
}
let consoleLogSpy: ReturnType<typeof spyOn>
let consoleErrorSpy: ReturnType<typeof spyOn>
function abortAfter(abortController: AbortController, delayMs: number): void {
setTimeout(() => abortController.abort(), delayMs)
}
beforeEach(() => {
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {})
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {})
})
afterEach(() => {
consoleLogSpy.mockRestore()
consoleErrorSpy.mockRestore()
})
describe("pollForCompletion", () => {
it("requires consecutive stability checks before exiting - not immediate", async () => {
//#given - 0 todos, 0 children, session idle, meaningful work done
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -56,8 +71,6 @@ describe("pollForCompletion", () => {
it("does not check completion during stabilization period after first meaningful work", async () => {
//#given - session idle, meaningful work done, but stabilization period not elapsed
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -65,7 +78,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort after 50ms (within the 60ms stabilization period)
setTimeout(() => abortController.abort(), 50)
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -80,8 +93,6 @@ describe("pollForCompletion", () => {
it("does not exit when currentTool is set - resets consecutive counter", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -90,7 +101,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort after enough time to verify it didn't exit
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -105,8 +116,6 @@ describe("pollForCompletion", () => {
it("resets consecutive counter when session becomes busy between checks", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -147,8 +156,6 @@ describe("pollForCompletion", () => {
it("returns 1 on session error", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -169,14 +176,12 @@ describe("pollForCompletion", () => {
it("returns 130 when aborted", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 50)
abortAfter(abortController, 50)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -188,8 +193,6 @@ describe("pollForCompletion", () => {
it("does not check completion when hasReceivedMeaningfulWork is false", async () => {
//#given
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -197,7 +200,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -211,8 +214,6 @@ describe("pollForCompletion", () => {
it("falls back to session.status API when idle event is missing", async () => {
//#given - mainSessionIdle not set by events, but status API says idle
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext({
statuses: {
"test-session": { type: "idle" },
@@ -236,8 +237,6 @@ describe("pollForCompletion", () => {
it("allows silent completion after stabilization when no meaningful work is received", async () => {
//#given - session is idle and stable but no assistant message/tool event arrived
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -257,8 +256,6 @@ describe("pollForCompletion", () => {
it("uses default stabilization to avoid indefinite wait when no meaningful work arrives", async () => {
//#given - idle with no meaningful work and no explicit minStabilization override
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -277,8 +274,6 @@ describe("pollForCompletion", () => {
it("coerces non-positive stabilization values to default stabilization", async () => {
//#given - explicit zero stabilization should still wait for default window
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -286,7 +281,7 @@ describe("pollForCompletion", () => {
const abortController = new AbortController()
//#when - abort before default 1s window elapses
setTimeout(() => abortController.abort(), 100)
abortAfter(abortController, 100)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 1,
@@ -299,8 +294,6 @@ describe("pollForCompletion", () => {
it("simulates race condition: brief idle with 0 todos does not cause immediate exit", async () => {
//#given - simulate Sisyphus outputting text, session goes idle briefly, then tool fires
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
@@ -323,7 +316,7 @@ describe("pollForCompletion", () => {
)
//#when - abort after tool stays in-flight
setTimeout(() => abortController.abort(), 200)
abortAfter(abortController, 200)
const result = await pollForCompletion(ctx, eventState, abortController, {
pollIntervalMs: 10,
requiredConsecutive: 3,
@@ -335,8 +328,6 @@ describe("pollForCompletion", () => {
it("returns 1 when session errors while not idle (error not masked by idle gate)", async () => {
//#given - mainSessionIdle=false, mainSessionError=true, lastError="crash"
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = false
@@ -359,8 +350,6 @@ describe("pollForCompletion", () => {
it("returns 1 when session errors while tool is active (error not masked by tool gate)", async () => {
//#given - mainSessionIdle=true, currentTool="bash", mainSessionError=true
spyOn(console, "log").mockImplementation(() => {})
spyOn(console, "error").mockImplementation(() => {})
const ctx = createMockContext()
const eventState = createEventState()
eventState.mainSessionIdle = true
+7
View File
@@ -7,6 +7,7 @@ import { resolveSession } from "./session-resolver"
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"
@@ -46,6 +47,7 @@ export async function run(options: RunOptions): Promise<number> {
const pluginConfig = loadPluginConfig(directory, { command: "run" })
const resolvedAgent = resolveRunAgent(options, pluginConfig)
const resolvedModel = resolveRunModel(options.model)
const abortController = new AbortController()
try {
@@ -78,6 +80,10 @@ export async function run(options: RunOptions): Promise<number> {
console.log(pc.dim(`Session: ${sessionID}`))
if (resolvedModel) {
console.log(pc.dim(`Model: ${resolvedModel.providerID}/${resolvedModel.modelID}`))
}
const ctx: RunContext = {
client,
sessionID,
@@ -96,6 +102,7 @@ export async function run(options: RunOptions): Promise<number> {
path: { id: sessionID },
body: {
agent: resolvedAgent,
...(resolvedModel ? { model: resolvedModel } : {}),
tools: {
question: false,
},
+1
View File
@@ -4,6 +4,7 @@ export type { OpencodeClient }
export interface RunOptions {
message: string
agent?: string
model?: string
timestamp?: boolean
verbose?: boolean
directory?: string
+1 -1
View File
@@ -93,7 +93,7 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`,
` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-openagent >/dev/null 2>&1 || true")}`,
)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
+19
View File
@@ -884,6 +884,25 @@ describe("GitMasterConfigSchema", () => {
//#then
expect(result.success).toBe(false)
})
test("accepts shell-safe git_env_prefix", () => {
const config = { git_env_prefix: "MY_HOOK=active" }
const result = GitMasterConfigSchema.safeParse(config)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.git_env_prefix).toBe("MY_HOOK=active")
}
})
test("rejects git_env_prefix with shell metacharacters", () => {
const config = { git_env_prefix: "A=1; rm -rf /" }
const result = GitMasterConfigSchema.safeParse(config)
expect(result.success).toBe(false)
})
})
describe("skills schema", () => {
+1
View File
@@ -10,6 +10,7 @@ export * from "./schema/commands"
export * from "./schema/dynamic-context-pruning"
export * from "./schema/experimental"
export * from "./schema/fallback-models"
export * from "./schema/git-env-prefix"
export * from "./schema/git-master"
export * from "./schema/hooks"
export * from "./schema/notification"
+1
View File
@@ -11,6 +11,7 @@ export const BuiltinAgentNameSchema = z.enum([
"metis",
"momus",
"atlas",
"sisyphus-junior",
])
export const BuiltinSkillNameSchema = z.enum([
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod"
const GIT_ENV_ASSIGNMENT_PATTERN =
/^(?:[A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)(?: [A-Za-z_][A-Za-z0-9_]*=[A-Za-z0-9_-]*)*$/
export const GIT_ENV_PREFIX_VALIDATION_MESSAGE =
'git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"'
export function isValidGitEnvPrefix(value: string): boolean {
if (value === "") {
return true
}
return GIT_ENV_ASSIGNMENT_PATTERN.test(value)
}
export function assertValidGitEnvPrefix(value: string): string {
if (!isValidGitEnvPrefix(value)) {
throw new Error(GIT_ENV_PREFIX_VALIDATION_MESSAGE)
}
return value
}
export const GitEnvPrefixSchema = z
.string()
.refine(isValidGitEnvPrefix, { message: GIT_ENV_PREFIX_VALIDATION_MESSAGE })
.default("GIT_MASTER=1")
+4
View File
@@ -1,10 +1,14 @@
import { z } from "zod"
import { GitEnvPrefixSchema } from "./git-env-prefix"
export const GitMasterConfigSchema = z.object({
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true). Can be boolean or custom string. */
commit_footer: z.union([z.boolean(), z.string()]).default(true),
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
include_co_authored_by: z.boolean().default(true),
/** Environment variable prefix for all git commands (default: "GIT_MASTER=1"). Set to "" to disable. Allows custom git hooks to detect git-master skill usage. */
git_env_prefix: GitEnvPrefixSchema,
})
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
@@ -0,0 +1,49 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
describe("BackgroundManager session permission", () => {
test("passes explicit session permission rules to child session creation", async () => {
// given
const createCalls: Array<Record<string, unknown>> = []
const client = {
session: {
get: async () => ({ data: { directory: "/parent" } }),
create: async (input: Record<string, unknown>) => {
createCalls.push(input)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
// when
await manager.launch({
description: "Test task",
prompt: "Do something",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
await new Promise(resolve => setTimeout(resolve, 50))
manager.shutdown()
// then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body).toEqual({
parentID: "ses_parent",
title: "Test task (@explore subagent)",
permission: [
{ permission: "question", action: "deny", pattern: "*" },
],
})
})
})
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import { BackgroundManager } from "./manager"
import type { BackgroundTask } from "./types"
function createManagerWithStatus(statusImpl: () => Promise<{ data: Record<string, { type: string }> }>): BackgroundManager {
const client = {
@@ -51,3 +52,105 @@ describe("BackgroundManager polling overlap", () => {
expect(statusCallCount).toBe(1)
})
})
function createRunningTask(sessionID: string): BackgroundTask {
return {
id: `bg_test_${sessionID}`,
sessionID,
parentSessionID: "parent-session",
parentMessageID: "parent-msg",
description: "test task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
progress: { toolCalls: 0, lastUpdate: new Date() },
}
}
function injectTask(manager: BackgroundManager, task: BackgroundTask): void {
const tasks = (manager as unknown as { tasks: Map<string, BackgroundTask> }).tasks
tasks.set(task.id, task)
}
function createManagerWithClient(clientOverrides: Record<string, unknown> = {}): BackgroundManager {
const client = {
session: {
status: async () => ({ data: {} }),
prompt: async () => ({}),
promptAsync: async () => ({}),
abort: async () => ({}),
todo: async () => ({ data: [] }),
messages: async () => ({
data: [{
info: { role: "assistant", finish: "end_turn", id: "msg-2" },
parts: [{ type: "text", text: "done" }],
}, {
info: { role: "user", id: "msg-1" },
parts: [{ type: "text", text: "go" }],
}],
}),
...clientOverrides,
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
}
describe("BackgroundManager pollRunningTasks", () => {
describe("#given a running task whose session is no longer in status response", () => {
test("#when pollRunningTasks runs #then completes the task instead of leaving it running", async () => {
//#given
const manager = createManagerWithClient()
const task = createRunningTask("ses-gone")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
expect(task.completedAt).toBeDefined()
})
})
describe("#given a running task whose session status is idle", () => {
test("#when pollRunningTasks runs #then completes the task", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-idle": { type: "idle" } } }),
})
const task = createRunningTask("ses-idle")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("completed")
})
})
describe("#given a running task whose session status is busy", () => {
test("#when pollRunningTasks runs #then keeps the task running", async () => {
//#given
const manager = createManagerWithClient({
status: async () => ({ data: { "ses-busy": { type: "busy" } } }),
})
const task = createRunningTask("ses-busy")
injectTask(manager, task)
//#when
const poll = (manager as unknown as { pollRunningTasks: () => Promise<void> }).pollRunningTasks
await poll.call(manager)
manager.shutdown()
//#then
expect(task.status).toBe("running")
})
})
})
+109 -13
View File
@@ -1334,6 +1334,100 @@ describe("BackgroundManager.tryCompleteTask", () => {
expect(getPendingByParent(manager).get(task.parentSessionID)).toBeUndefined()
})
test("should remove toast tracking before notifying completed task", async () => {
// given
const { removeTaskCalls, resetToastManager } = createToastRemoveTaskTracker()
const task: BackgroundTask = {
id: "task-toast-complete",
sessionID: "session-toast-complete",
parentSessionID: "parent-toast-complete",
parentMessageID: "msg-1",
description: "toast completion task",
prompt: "test",
agent: "explore",
status: "running",
startedAt: new Date(),
}
try {
// when
await tryCompleteTaskForTest(manager, task)
// then
expect(removeTaskCalls).toContain(task.id)
} finally {
resetToastManager()
}
})
test("should release task concurrencyKey when startTask throws after assigning it", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-concurrency",
sessionID: "session-process-key-concurrency",
parentSessionID: "parent-process-key-concurrency",
status: "pending",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
;(manager as unknown as { startTask: (item: { task: BackgroundTask; input: typeof input }) => Promise<void> }).startTask = async (item) => {
item.task.concurrencyKey = concurrencyKey
throw new Error("startTask failed after assigning concurrencyKey")
}
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(task.concurrencyKey).toBeUndefined()
})
test("should release queue slot when queued task is already interrupt", async () => {
// given
const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager)
const task = createMockTask({
id: "task-process-key-interrupt",
sessionID: "session-process-key-interrupt",
parentSessionID: "parent-process-key-interrupt",
status: "interrupt",
agent: "explore",
})
const input = {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
}
getTaskMap(manager).set(task.id, task)
getQueuesByKey(manager).set(concurrencyKey, [{ task, input }])
// when
await processKeyForTest(manager, concurrencyKey)
// then
expect(concurrencyManager.getCount(concurrencyKey)).toBe(0)
expect(getQueuesByKey(manager).get(concurrencyKey)).toEqual([])
})
test("should avoid overlapping promptAsync calls when tasks complete concurrently", async () => {
// given
type PromptAsyncBody = Record<string, unknown> & { noReply?: boolean }
@@ -3189,7 +3283,7 @@ describe("BackgroundManager.handleEvent - session.error", () => {
concurrencyKey,
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["anthropic"], model: "claude-opus-4-5" },
{ providers: ["anthropic"], model: "claude-opus-4-5", variant: "max" },
],
})
@@ -3271,21 +3365,23 @@ describe("BackgroundManager.handleEvent - session.error", () => {
})
//#when
const messageInfo = {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
}
manager.handleEvent({
type: "message.updated",
properties: {
info: {
id: "msg_errored",
sessionID,
role: "assistant",
error: {
name: "UnknownError",
data: {
message:
"Bad Gateway: {\"error\":{\"message\":\"unknown provider for model claude-opus-4-6-thinking\"}}",
},
},
},
info: messageInfo,
},
})
+51 -40
View File
@@ -50,6 +50,7 @@ import { MESSAGE_STORAGE } from "../hook-message-injector"
import { join } from "node:path"
import { pruneStaleTasksAndNotifications } from "./task-poller"
import { checkAndInterruptStaleTasks } from "./task-poller"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
type OpencodeClient = PluginInput["client"]
@@ -225,7 +226,7 @@ export class BackgroundManager {
await this.concurrencyManager.acquire(key)
if (item.task.status === "cancelled" || item.task.status === "error") {
if (item.task.status === "cancelled" || item.task.status === "error" || item.task.status === "interrupt") {
this.concurrencyManager.release(key)
queue.shift()
continue
@@ -235,9 +236,10 @@ export class BackgroundManager {
await this.startTask(item)
} catch (error) {
log("[background-agent] Error starting task:", error)
// Release concurrency slot if startTask failed and didn't release it itself
// This prevents slot leaks when errors occur after acquire but before task.concurrencyKey is set
if (!item.task.concurrencyKey) {
if (item.task.concurrencyKey) {
this.concurrencyManager.release(item.task.concurrencyKey)
item.task.concurrencyKey = undefined
} else {
this.concurrencyManager.release(key)
}
}
@@ -273,6 +275,7 @@ export class BackgroundManager {
body: {
parentID: input.parentSessionID,
title: `${input.description} (@${input.agent} subagent)`,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -387,6 +390,8 @@ export class BackgroundManager {
existingTask.concurrencyKey = undefined
}
removeTaskToastTracking(existingTask.id)
// Abort the session to prevent infinite polling hang
this.client.session.abort({
path: { id: sessionID },
@@ -656,6 +661,8 @@ export class BackgroundManager {
existingTask.concurrencyKey = undefined
}
removeTaskToastTracking(existingTask.id)
// Abort the session to prevent infinite polling hang
if (existingTask.sessionID) {
this.client.session.abort({
@@ -1107,11 +1114,9 @@ export class BackgroundManager {
SessionCategoryRegistry.remove(task.sessionID)
}
removeTaskToastTracking(task.id)
if (options?.skipNotification) {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(task.id)
}
log(`[background-agent] Task cancelled via ${source} (notification skipped):`, task.id)
return true
}
@@ -1197,6 +1202,8 @@ export class BackgroundManager {
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
removeTaskToastTracking(task.id)
// Release concurrency BEFORE any async operations to prevent slot leaks
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
@@ -1444,6 +1451,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined
}
removeTaskToastTracking(task.id)
this.cleanupPendingByParent(task)
if (wasPending) {
const key = task.model
@@ -1506,32 +1514,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
try {
const sessionStatus = allStatuses[sessionID]
if (sessionStatus?.type === "idle") {
// Edge guard: Validate session has actual output before completing
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, "polling (idle status)")
continue
}
// Session is still actively running (not idle).
// Progress is already tracked via handleEvent(message.part.updated),
// so we skip the expensive session.messages() fetch here.
// Completion will be detected when session transitions to idle.
// Handle retry before checking running state
if (sessionStatus?.type === "retry") {
const retryMessage = typeof (sessionStatus as { message?: string }).message === "string"
? (sessionStatus as { message?: string }).message
@@ -1542,12 +1525,40 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
}
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus?.type ?? "not_in_status",
toolCalls: task.progress?.toolCalls ?? 0,
})
// Match sync-session-poller pattern: only skip completion check when
// status EXISTS and is not idle (i.e., session is actively running).
// When sessionStatus is undefined, the session has completed and dropped
// from the status response — fall through to completion detection.
if (sessionStatus && sessionStatus.type !== "idle") {
log("[background-agent] Session still running, relying on event-based progress:", {
taskId: task.id,
sessionID,
sessionStatus: sessionStatus.type,
toolCalls: task.progress?.toolCalls ?? 0,
})
continue
}
// Session is idle or no longer in status response (completed/disappeared)
const completionSource = sessionStatus?.type === "idle"
? "polling (idle status)"
: "polling (session gone from status)"
const hasValidOutput = await this.validateSessionHasOutput(sessionID)
if (!hasValidOutput) {
log("[background-agent] Polling idle/gone but no valid output yet, waiting:", task.id)
continue
}
// Re-check status after async operation
if (task.status !== "running") continue
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
continue
}
await this.tryCompleteTask(task, completionSource)
} catch (error) {
log("[background-agent] Poll error for task:", { taskId: task.id, error })
}
@@ -0,0 +1,8 @@
import { getTaskToastManager } from "../task-toast-manager"
export function removeTaskToastTracking(taskId: string): void {
const toastManager = getTaskToastManager()
if (toastManager) {
toastManager.removeTask(taskId)
}
}
@@ -3,7 +3,7 @@ import { describe, test, expect } from "bun:test"
import { createTask, startTask } from "./spawner"
describe("background-agent spawner.startTask", () => {
test("does not override parent session permission rules when creating child session", async () => {
test("applies explicit child session permission rules when creating child session", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
@@ -41,6 +41,9 @@ describe("background-agent spawner.startTask", () => {
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
},
}
@@ -57,6 +60,8 @@ describe("background-agent spawner.startTask", () => {
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toBeUndefined()
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
})
})
+1
View File
@@ -61,6 +61,7 @@ export async function startTask(
const createResult = await client.session.create({
body: {
parentID: input.parentSessionID,
...(input.sessionPermission ? { permission: input.sessionPermission } : {}),
} as Record<string, unknown>,
query: {
directory: parentDirectory,
@@ -391,6 +391,31 @@ describe("checkAndInterruptStaleTasks", () => {
expect(releaseMock).toHaveBeenCalledWith("anthropic/claude-opus-4-6")
expect(task.concurrencyKey).toBeUndefined()
})
it("should invoke interruption callback immediately when stale task is cancelled", async () => {
//#given
const task = createRunningTask({
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 200_000),
},
})
const onTaskInterrupted = mock(() => {})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
onTaskInterrupted,
})
//#then
expect(task.status).toBe("cancelled")
expect(onTaskInterrupted).toHaveBeenCalledWith(task)
})
})
describe("pruneStaleTasksAndNotifications", () => {
+15 -1
View File
@@ -11,6 +11,7 @@ import {
MIN_RUNTIME_BEFORE_STALE_MS,
TASK_TTL_MS,
} from "./constants"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
export function pruneStaleTasksAndNotifications(args: {
tasks: Map<string, BackgroundTask>
@@ -66,8 +67,17 @@ export async function checkAndInterruptStaleTasks(args: {
concurrencyManager: ConcurrencyManager
notifyParentSession: (task: BackgroundTask) => Promise<void>
sessionStatuses?: SessionStatusMap
onTaskInterrupted?: (task: BackgroundTask) => void
}): Promise<void> {
const { tasks, client, config, concurrencyManager, notifyParentSession, sessionStatuses } = args
const {
tasks,
client,
config,
concurrencyManager,
notifyParentSession,
sessionStatuses,
onTaskInterrupted = (task) => removeTaskToastTracking(task.id),
} = args
const staleTimeoutMs = config?.staleTimeoutMs ?? DEFAULT_STALE_TIMEOUT_MS
const now = Date.now()
@@ -98,6 +108,8 @@ export async function checkAndInterruptStaleTasks(args: {
task.concurrencyKey = undefined
}
onTaskInterrupted(task)
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: no progress since start`)
@@ -127,6 +139,8 @@ export async function checkAndInterruptStaleTasks(args: {
task.concurrencyKey = undefined
}
onTaskInterrupted(task)
client.session.abort({ path: { id: sessionID } }).catch(() => {})
log(`[background-agent] Task ${task.id} interrupted: stale timeout`)
+2
View File
@@ -1,4 +1,5 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
export type BackgroundTaskStatus =
| "pending"
@@ -72,6 +73,7 @@ export interface LaunchInput {
skills?: string[]
skillContent?: string
category?: string
sessionPermission?: SessionPermissionRule[]
}
export interface ResumeInput {
@@ -0,0 +1,108 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
describe("mapClaudeModelToOpenCode", () => {
describe("#given undefined or empty input", () => {
it("#when called with undefined #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(undefined)).toBeUndefined()
})
it("#when called with empty string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("")).toBeUndefined()
})
it("#when called with whitespace-only string #then returns undefined", () => {
expect(mapClaudeModelToOpenCode(" ")).toBeUndefined()
})
})
describe("#given Claude Code alias", () => {
it("#when called with sonnet #then maps to anthropic claude-sonnet-4-6 object", () => {
expect(mapClaudeModelToOpenCode("sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
it("#when called with opus #then maps to anthropic claude-opus-4-6 object", () => {
expect(mapClaudeModelToOpenCode("opus")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})
it("#when called with haiku #then maps to anthropic claude-haiku-4-5 object", () => {
expect(mapClaudeModelToOpenCode("haiku")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5" })
})
it("#when called with Sonnet (capitalized) #then maps case-insensitively to object", () => {
expect(mapClaudeModelToOpenCode("Sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})
describe("#given inherit", () => {
it("#when called with inherit #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("inherit")).toBeUndefined()
})
})
describe("#given bare Claude model name", () => {
it("#when called with claude-sonnet-4-5-20250514 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-sonnet-4-5-20250514")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-5-20250514" })
})
it("#when called with claude-opus-4-6 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-opus-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
})
it("#when called with claude-haiku-4-5-20251001 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-haiku-4-5-20251001")).toEqual({ providerID: "anthropic", modelID: "claude-haiku-4-5-20251001" })
})
it("#when called with claude-3-5-sonnet-20241022 #then adds anthropic object format", () => {
expect(mapClaudeModelToOpenCode("claude-3-5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})
describe("#given model with dot version numbers", () => {
it("#when called with claude-3.5-sonnet #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet" })
})
it("#when called with claude-3.5-sonnet-20241022 #then normalizes dots and returns object format", () => {
expect(mapClaudeModelToOpenCode("claude-3.5-sonnet-20241022")).toEqual({ providerID: "anthropic", modelID: "claude-3-5-sonnet-20241022" })
})
})
describe("#given model already in provider/model format", () => {
it("#when called with anthropic/claude-sonnet-4-6 #then splits into object format", () => {
expect(mapClaudeModelToOpenCode("anthropic/claude-sonnet-4-6")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
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" })
})
})
describe("#given non-Claude bare model", () => {
it("#when called with gpt-5.2 #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gpt-5.2")).toBeUndefined()
})
it("#when called with gemini-3-flash #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("gemini-3-flash")).toBeUndefined()
})
})
describe("#given prototype property name", () => {
it("#when called with constructor #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("constructor")).toBeUndefined()
})
it("#when called with toString #then returns undefined", () => {
expect(mapClaudeModelToOpenCode("toString")).toBeUndefined()
})
})
describe("#given model with leading/trailing whitespace", () => {
it("#when called with padded string #then trims before returning object format", () => {
expect(mapClaudeModelToOpenCode(" claude-sonnet-4-6 ")).toEqual({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
})
})
})
@@ -0,0 +1,39 @@
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { normalizeModelID } from "../../shared/model-normalization"
const ANTHROPIC_PREFIX = "anthropic/"
const CLAUDE_CODE_ALIAS_MAP = new Map<string, string>([
["sonnet", `${ANTHROPIC_PREFIX}claude-sonnet-4-6`],
["opus", `${ANTHROPIC_PREFIX}claude-opus-4-6`],
["haiku", `${ANTHROPIC_PREFIX}claude-haiku-4-5`],
])
function mapClaudeModelString(model: string | undefined): string | undefined {
if (!model) return undefined
const trimmed = model.trim()
if (trimmed.length === 0) return undefined
if (trimmed === "inherit") return undefined
const aliasResult = CLAUDE_CODE_ALIAS_MAP.get(trimmed.toLowerCase())
if (aliasResult) return aliasResult
if (trimmed.includes("/")) return trimmed
const normalized = normalizeModelID(trimmed)
if (normalized.startsWith("claude-")) {
return `${ANTHROPIC_PREFIX}${normalized}`
}
return undefined
}
export function mapClaudeModelToOpenCode(
model: string | undefined
): { providerID: string; modelID: string } | undefined {
const mappedModel = mapClaudeModelString(model)
return mappedModel ? normalizeModelFormat(mappedModel) : undefined
}
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
import type { AgentScope, AgentFrontmatter, ClaudeCodeAgentConfig, LoadedAgent } from "./types"
import { mapClaudeModelToOpenCode } from "./claude-model-mapper"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
@@ -42,10 +42,13 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
const formattedDescription = `(${scope}) ${originalDescription}`
const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: "subagent",
mode: data.mode || "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
}
const toolsConfig = parseToolsConfig(data.tools)
@@ -67,22 +70,22 @@ function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[]
return agents
}
export function loadUserAgents(): Record<string, AgentConfig> {
export function loadUserAgents(): Record<string, ClaudeCodeAgentConfig> {
const userAgentsDir = join(getClaudeConfigDir(), "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadProjectAgents(directory?: string): Record<string, AgentConfig> {
export function loadProjectAgents(directory?: string): Record<string, ClaudeCodeAgentConfig> {
const projectAgentsDir = join(directory ?? process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, AgentConfig> = {}
const result: Record<string, ClaudeCodeAgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
@@ -2,16 +2,21 @@ import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentScope = "user" | "project"
export type ClaudeCodeAgentConfig = Omit<AgentConfig, "model"> & {
model?: string | { providerID: string; modelID: string }
}
export interface AgentFrontmatter {
name?: string
description?: string
model?: string
tools?: string
mode?: "subagent" | "primary" | "all"
}
export interface LoadedAgent {
name: string
path: string
config: AgentConfig
config: ClaudeCodeAgentConfig
scope: AgentScope
}
@@ -1,10 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { basename, join } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import { isMarkdownFile } from "../../shared/file-utils"
import { log } from "../../shared/logger"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { AgentFrontmatter, ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import { mapClaudeModelToOpenCode } from "../claude-code-agent-loader/claude-model-mapper"
import type { LoadedPlugin } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
@@ -24,8 +24,8 @@ function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefine
return result
}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentConfig> {
const agents: Record<string, AgentConfig> = {}
export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, ClaudeCodeAgentConfig> {
const agents: Record<string, ClaudeCodeAgentConfig> = {}
for (const plugin of plugins) {
if (!plugin.agentsDir || !existsSync(plugin.agentsDir)) continue
@@ -46,10 +46,13 @@ export function loadPluginAgents(plugins: LoadedPlugin[]): Record<string, AgentC
const originalDescription = data.description || ""
const formattedDescription = `(plugin: ${plugin.name}) ${originalDescription}`
const config: AgentConfig = {
const mappedModelOverride = mapClaudeModelToOpenCode(data.model)
const config: ClaudeCodeAgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
...(mappedModelOverride ? { model: mappedModelOverride } : {}),
}
const toolsConfig = parseToolsConfig(data.tools)
@@ -1,7 +1,7 @@
import { log } from "../../shared/logger"
import type { AgentConfig } from "@opencode-ai/sdk"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { McpServerConfig } from "../claude-code-mcp-loader/types"
import type { ClaudeCodeAgentConfig } from "../claude-code-agent-loader/types"
import type { HooksConfig, LoadedPlugin, PluginLoadError, PluginLoaderOptions } from "./types"
import { discoverInstalledPlugins } from "./discovery"
import { loadPluginCommands } from "./command-loader"
@@ -20,7 +20,7 @@ export { loadPluginHooksConfigs } from "./hook-loader"
export interface PluginComponentsResult {
commands: Record<string, CommandDefinition>
skills: Record<string, CommandDefinition>
agents: Record<string, AgentConfig>
agents: Record<string, ClaudeCodeAgentConfig>
mcpServers: Record<string, McpServerConfig>
hooksConfigs: HooksConfig[]
plugins: LoadedPlugin[]
@@ -58,7 +58,7 @@ function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null
// TODO: These SDK-based functions are exported for future use when hooks migrate to async.
// Currently, callers still use the sync JSON-based functions which return null on beta.
// Migration requires making callers async, which is a larger refactoring.
// See: https://github.com/code-yeongyu/oh-my-opencode/pull/1837
// See: https://github.com/code-yeongyu/oh-my-openagent/pull/1837
/**
* Finds the nearest message with required fields using SDK (for beta/SQLite backend).
@@ -0,0 +1,155 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import { injectGitMasterConfig } from "./git-master-template-injection"
const SAMPLE_TEMPLATE = [
"# Git Master Agent",
"",
"## MODE DETECTION (FIRST STEP)",
"",
"Analyze the request.",
"",
"```bash",
"git status",
"git merge-base HEAD main 2>/dev/null || git merge-base HEAD master 2>/dev/null",
"MERGE_BASE=$(git merge-base HEAD main)",
"GIT_SEQUENCE_EDITOR=: git rebase -i --autosquash $MERGE_BASE",
"```",
"",
"```",
"</execution>",
].join("\n")
describe("#given git_env_prefix config", () => {
describe("#when default config (GIT_MASTER=1)", () => {
it("#then injects env prefix section before MODE DETECTION", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("GIT_MASTER=1 git push")
expect(result).toContain("EVERY git command MUST be prefixed with `GIT_MASTER=1`")
const prefixIndex = result.indexOf("## GIT COMMAND PREFIX")
const modeIndex = result.indexOf("## MODE DETECTION")
expect(prefixIndex).toBeLessThan(modeIndex)
})
})
describe("#when git_env_prefix is empty string", () => {
it("#then does NOT inject env prefix section", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "",
})
expect(result).not.toContain("## GIT COMMAND PREFIX")
expect(result).not.toContain("GIT_MASTER=1")
expect(result).not.toContain("git_env_prefix")
})
})
describe("#when git_env_prefix is custom value", () => {
it("#then injects custom prefix in section", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "MY_HOOK=active",
})
expect(result).toContain("MY_HOOK=active git status")
expect(result).toContain("MY_HOOK=active git commit")
expect(result).not.toContain("GIT_MASTER=1")
})
})
describe("#when git_env_prefix contains shell metacharacters", () => {
it("#then rejects the malicious value", () => {
expect(() =>
injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "A=1; rm -rf /",
})
).toThrow('git_env_prefix must be empty or use shell-safe env assignments like "GIT_MASTER=1"')
})
})
describe("#when no config provided", () => {
it("#then uses default GIT_MASTER=1 prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE)
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("## GIT COMMAND PREFIX (MANDATORY)")
})
})
})
describe("#given git_env_prefix with commit footer", () => {
describe("#when both env prefix and footer are enabled", () => {
it("#then commit examples include the env prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("Ultraworked with [Sisyphus]")
})
})
describe("#when the template already contains bare git commands in bash blocks", () => {
it("#then prefixes every git invocation in the final output", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain(
"GIT_MASTER=1 git merge-base HEAD main 2>/dev/null || GIT_MASTER=1 git merge-base HEAD master 2>/dev/null"
)
expect(result).toContain("MERGE_BASE=$(GIT_MASTER=1 git merge-base HEAD main)")
expect(result).toContain(
"GIT_SEQUENCE_EDITOR=: GIT_MASTER=1 git rebase -i --autosquash $MERGE_BASE"
)
})
})
describe("#when env prefix disabled but footer enabled", () => {
it("#then commit examples have no env prefix", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "",
})
expect(result).not.toContain("GIT_MASTER=1 git commit")
expect(result).toContain("git commit -m")
expect(result).toContain("Ultraworked with [Sisyphus]")
})
})
describe("#when both env prefix and co-author are enabled", () => {
it("#then commit example includes prefix, footer, and co-author", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("Ultraworked with [Sisyphus]")
expect(result).toContain("Co-authored-by: Sisyphus")
})
})
})
@@ -1,14 +1,88 @@
import type { GitMasterConfig } from "../../config/schema"
import { assertValidGitEnvPrefix, type GitMasterConfig } from "../../config/schema"
const BASH_CODE_BLOCK_PATTERN = /```bash\r?\n([\s\S]*?)```/g
const LEADING_GIT_COMMAND_PATTERN = /^([ \t]*(?:[A-Za-z_][A-Za-z0-9_]*=[^ \t]+\s+)*)git(?=[ \t]|$)/gm
const INLINE_GIT_COMMAND_PATTERN = /([;&|()][ \t]*)git(?=[ \t]|$)/g
export function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
const commitFooter = config?.commit_footer ?? true
const includeCoAuthoredBy = config?.include_co_authored_by ?? true
const gitEnvPrefix = assertValidGitEnvPrefix(config?.git_env_prefix ?? "GIT_MASTER=1")
if (!commitFooter && !includeCoAuthoredBy) {
return template
let result = gitEnvPrefix ? injectGitEnvPrefix(template, gitEnvPrefix) : template
if (commitFooter || includeCoAuthoredBy) {
const injection = buildCommitFooterInjection(commitFooter, includeCoAuthoredBy, gitEnvPrefix)
const insertionPoint = result.indexOf("```\n</execution>")
result =
insertionPoint !== -1
? result.slice(0, insertionPoint) +
"```\n\n" +
injection +
"\n</execution>" +
result.slice(insertionPoint + "```\n</execution>".length)
: result + "\n\n" + injection
}
return gitEnvPrefix ? prefixGitCommandsInBashCodeBlocks(result, gitEnvPrefix) : result
}
function injectGitEnvPrefix(template: string, prefix: string): string {
const envPrefixSection = [
"## GIT COMMAND PREFIX (MANDATORY)",
"",
`<git_env_prefix>`,
`**EVERY git command MUST be prefixed with \`${prefix}\`.**`,
"",
"This allows custom git hooks to detect when git-master skill is active.",
"",
"```bash",
`${prefix} git status`,
`${prefix} git add <files>`,
`${prefix} git commit -m "message"`,
`${prefix} git push`,
`${prefix} git rebase ...`,
`${prefix} git log ...`,
"```",
"",
"**NO EXCEPTIONS. Every `git` invocation must include this prefix.**",
`</git_env_prefix>`,
].join("\n")
const modeDetectionMarker = "## MODE DETECTION (FIRST STEP)"
const markerIndex = template.indexOf(modeDetectionMarker)
if (markerIndex !== -1) {
return (
template.slice(0, markerIndex) +
envPrefixSection +
"\n\n---\n\n" +
template.slice(markerIndex)
)
}
return envPrefixSection + "\n\n---\n\n" + template
}
function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): string {
return template.replace(BASH_CODE_BLOCK_PATTERN, (block, codeBlock: string) => {
return block.replace(codeBlock, prefixGitCommandsInCodeBlock(codeBlock, prefix))
})
}
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
return codeBlock
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
}
function buildCommitFooterInjection(
commitFooter: boolean | string,
includeCoAuthoredBy: boolean,
gitEnvPrefix: string,
): string {
const sections: string[] = []
const cmdPrefix = gitEnvPrefix ? `${gitEnvPrefix} ` : ""
sections.push("### 5.5 Commit Footer & Co-Author")
sections.push("")
@@ -19,7 +93,7 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
const footerText =
typeof commitFooter === "string"
? commitFooter
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)"
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)"
sections.push("1. **Footer in commit body:**")
sections.push("```")
sections.push(footerText)
@@ -39,43 +113,30 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
const footerText =
typeof commitFooter === "string"
? commitFooter
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)"
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)"
sections.push("**Example (both enabled):**")
sections.push("```bash")
sections.push(
`git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
)
sections.push("```")
} else if (commitFooter) {
const footerText =
typeof commitFooter === "string"
? commitFooter
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)"
: "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)"
sections.push("**Example:**")
sections.push("```bash")
sections.push(`git commit -m "{Commit Message}" -m "${footerText}"`)
sections.push(`${cmdPrefix}git commit -m "{Commit Message}" -m "${footerText}"`)
sections.push("```")
} else if (includeCoAuthoredBy) {
sections.push("**Example:**")
sections.push("```bash")
sections.push(
"git commit -m \"{Commit Message}\" -m \"Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>\""
`${cmdPrefix}git commit -m "{Commit Message}" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"`
)
sections.push("```")
}
const injection = sections.join("\n")
const insertionPoint = template.indexOf("```\n</execution>")
if (insertionPoint !== -1) {
return (
template.slice(0, insertionPoint) +
"```\n\n" +
injection +
"\n</execution>" +
template.slice(insertionPoint + "```\n</execution>".length)
)
}
return template + "\n\n" + injection
return sections.join("\n")
}
@@ -228,6 +228,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -249,6 +250,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -269,6 +271,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -302,6 +305,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: false,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -322,6 +326,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: customFooter,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -341,6 +346,7 @@ describe("resolveMultipleSkillsAsync", () => {
gitMasterConfig: {
commit_footer: true,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
},
}
@@ -0,0 +1,125 @@
import type { TmuxPaneInfo } from "./types"
const MANDATORY_PANE_FIELD_COUNT = 8
type ParsedPaneState = {
windowWidth: number
windowHeight: number
panes: TmuxPaneInfo[]
}
type ParsedPaneLine = {
pane: TmuxPaneInfo
windowWidth: number
windowHeight: number
}
type MandatoryPaneFields = [
paneId: string,
widthString: string,
heightString: string,
leftString: string,
topString: string,
activeString: string,
windowWidthString: string,
windowHeightString: string,
]
export function parsePaneStateOutput(stdout: string): ParsedPaneState | null {
const lines = stdout
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.length > 0)
if (lines.length === 0) return null
const parsedPaneLines = lines
.map(parsePaneLine)
.filter((parsedPaneLine): parsedPaneLine is ParsedPaneLine => parsedPaneLine !== null)
if (parsedPaneLines.length === 0) return null
const latestPaneLine = parsedPaneLines[parsedPaneLines.length - 1]
if (!latestPaneLine) return null
return {
windowWidth: latestPaneLine.windowWidth,
windowHeight: latestPaneLine.windowHeight,
panes: parsedPaneLines.map(({ pane }) => pane),
}
}
function parsePaneLine(line: string): ParsedPaneLine | null {
const fields = line.split("\t")
const mandatoryFields = getMandatoryPaneFields(fields)
if (!mandatoryFields) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = mandatoryFields
const width = parseInteger(widthString)
const height = parseInteger(heightString)
const left = parseInteger(leftString)
const top = parseInteger(topString)
const windowWidth = parseInteger(windowWidthString)
const windowHeight = parseInteger(windowHeightString)
if (
width === null ||
height === null ||
left === null ||
top === null ||
windowWidth === null ||
windowHeight === null
) {
return null
}
return {
pane: {
paneId,
width,
height,
left,
top,
title: fields.slice(MANDATORY_PANE_FIELD_COUNT).join("\t"),
isActive: activeString === "1",
},
windowWidth,
windowHeight,
}
}
function getMandatoryPaneFields(fields: string[]): MandatoryPaneFields | null {
if (fields.length < MANDATORY_PANE_FIELD_COUNT) return null
const [paneId, widthString, heightString, leftString, topString, activeString, windowWidthString, windowHeightString] = fields
if (
paneId === undefined ||
widthString === undefined ||
heightString === undefined ||
leftString === undefined ||
topString === undefined ||
activeString === undefined ||
windowWidthString === undefined ||
windowHeightString === undefined
) {
return null
}
return [
paneId,
widthString,
heightString,
leftString,
topString,
activeString,
windowWidthString,
windowHeightString,
]
}
function parseInteger(value: string): number | null {
const parsedValue = Number.parseInt(value, 10)
return Number.isNaN(parsedValue) ? null : parsedValue
}
@@ -0,0 +1,75 @@
/// <reference types="bun-types/test" />
import { describe, expect, it } from "bun:test"
import { parsePaneStateOutput } from "./pane-state-parser"
describe("parsePaneStateOutput", () => {
it("accepts a single pane when tmux omits the empty trailing title field", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result).toEqual({
windowWidth: 120,
windowHeight: 40,
panes: [
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
],
})
})
it("handles CRLF line endings without dropping panes", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\r\n%1\t60\t40\t60\t0\t0\t120\t40\tagent\r\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result?.panes).toEqual([
{
paneId: "%0",
width: 120,
height: 40,
left: 0,
top: 0,
title: "",
isActive: true,
},
{
paneId: "%1",
width: 60,
height: 40,
left: 60,
top: 0,
title: "agent",
isActive: false,
},
])
})
it("preserves tabs inside pane titles", () => {
// given
const stdout = "%0\t120\t40\t0\t0\t1\t120\t40\ttitle\twith\ttabs\n"
// when
const result = parsePaneStateOutput(stdout)
// then
expect(result).not.toBe(null)
expect(result?.panes[0]?.title).toBe("title\twith\ttabs")
})
})
@@ -1,5 +1,6 @@
import { spawn } from "bun"
import type { WindowState, TmuxPaneInfo } from "./types"
import { parsePaneStateOutput } from "./pane-state-parser"
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
import { log } from "../../shared"
@@ -27,31 +28,12 @@ export async function queryWindowState(sourcePaneId: string): Promise<WindowStat
return null
}
const lines = stdout.trim().split("\n").filter(Boolean)
if (lines.length === 0) return null
const parsedPaneState = parsePaneStateOutput(stdout)
if (!parsedPaneState) return null
let windowWidth = 0
let windowHeight = 0
const panes: TmuxPaneInfo[] = []
for (const line of lines) {
const fields = line.split("\t")
if (fields.length < 9) continue
const [paneId, widthStr, heightStr, leftStr, topStr, activeStr, windowWidthStr, windowHeightStr] = fields
const title = fields.slice(8).join("\t")
const width = parseInt(widthStr, 10)
const height = parseInt(heightStr, 10)
const left = parseInt(leftStr, 10)
const top = parseInt(topStr, 10)
const isActive = activeStr === "1"
windowWidth = parseInt(windowWidthStr, 10)
windowHeight = parseInt(windowHeightStr, 10)
if (!isNaN(width) && !isNaN(left) && !isNaN(height) && !isNaN(top)) {
panes.push({ paneId, width, height, left, top, title, isActive })
}
}
const { panes } = parsedPaneState
const windowWidth = parsedPaneState.windowWidth
const windowHeight = parsedPaneState.windowHeight
panes.sort((a, b) => a.left - b.left || a.top - b.top)
+2 -160
View File
@@ -1,18 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { getPlanProgress, readBoulderState } from "../../features/boulder-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name"
import { isAbortError } from "./is-abort-error"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { getLastAgentFromSession } from "./session-last-agent"
import { handleAtlasSessionIdle } from "./idle-event"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
export function createAtlasEventHandler(input: {
ctx: PluginInput
options?: AtlasHookOptions
@@ -39,157 +31,7 @@ export function createAtlasEventHandler(input: {
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Read boulder state FIRST to check if this session is part of an active boulder
const boulderState = readBoulderState(ctx.directory)
const isBoulderSession = boulderState?.session_ids?.includes(sessionID) ?? false
const isBackgroundTaskSession = subagentSessions.has(sessionID)
// Allow continuation only if: session is in boulder's session_ids OR is a background task
if (!isBackgroundTaskSession && !isBoulderSession) {
log(`[${HOOK_NAME}] Skipped: not boulder or background task session`, { sessionID })
return
}
const state = getState(sessionID)
const now = Date.now()
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (state.promptFailureCount >= 2) {
const timeSinceLastFailure = state.lastFailureAt !== undefined ? now - state.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
state.promptFailureCount = 0
state.lastFailureAt = undefined
}
const backgroundManager = options?.backgroundManager
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (!boulderState) {
log(`[${HOOK_NAME}] No active boulder`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
const sessionAgent = getSessionAgent(sessionID)
const lastAgent = await getLastAgentFromSession(sessionID, ctx.client)
const effectiveAgent = sessionAgent ?? lastAgent
const lastAgentKey = getAgentConfigKey(effectiveAgent ?? "")
const requiredAgent = getAgentConfigKey(boulderState.agent ?? "atlas")
const lastAgentMatchesRequired = lastAgentKey === requiredAgent
const boulderAgentDefaultsToAtlas = requiredAgent === "atlas"
const lastAgentIsSisyphus = lastAgentKey === "sisyphus"
const allowSisyphusForAtlasBoulder = boulderAgentDefaultsToAtlas && lastAgentIsSisyphus
const agentMatches = lastAgentMatchesRequired || allowSisyphusForAtlasBoulder
if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: effectiveAgent ?? "unknown",
requiredAgent,
})
return
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (state.lastContinuationInjectedAt && now - state.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
if (!state.pendingRetryTimer) {
state.pendingRetryTimer = setTimeout(async () => {
state.pendingRetryTimer = undefined
if (state.promptFailureCount >= 2) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
const hasBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t: { status: string }) => t.status === "running")
: false
if (hasBgTasks) return
state.lastContinuationInjectedAt = Date.now()
const currentRemaining = currentProgress.total - currentProgress.completed
try {
await injectBoulderContinuation({
ctx,
sessionID,
planName: currentBoulder.plan_name,
remaining: currentRemaining,
total: currentProgress.total,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
backgroundManager,
sessionState: state,
})
} catch (err) {
log(`[${HOOK_NAME}] Delayed retry failed`, { sessionID, error: err })
state.promptFailureCount++
}
}, RETRY_DELAY_MS)
}
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - state.lastContinuationInjectedAt),
pendingRetry: !!state.pendingRetryTimer,
})
return
}
state.lastContinuationInjectedAt = now
const remaining = progress.total - progress.completed
try {
await injectBoulderContinuation({
ctx,
sessionID,
planName: boulderState.plan_name,
remaining,
total: progress.total,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
backgroundManager,
sessionState: state,
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID, error: err })
state.promptFailureCount++
}
await handleAtlasSessionIdle({ ctx, options, getState, sessionID })
return
}
+215
View File
@@ -0,0 +1,215 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { appendSessionId, getPlanProgress, readBoulderState } from "../../features/boulder-state"
import type { BoulderState, PlanProgress } from "../../features/boulder-state"
import { subagentSessions } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
import type { AtlasHookOptions, SessionState } from "./types"
const CONTINUATION_COOLDOWN_MS = 5000
const FAILURE_BACKOFF_MS = 5 * 60 * 1000
const RETRY_DELAY_MS = CONTINUATION_COOLDOWN_MS + 1000
function hasRunningBackgroundTasks(sessionID: string, options?: AtlasHookOptions): boolean {
const backgroundManager = options?.backgroundManager
return backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((task: { status: string }) => task.status === "running")
: false
}
function resolveActiveBoulderSession(input: {
directory: string
sessionID: string
}): {
boulderState: BoulderState
progress: PlanProgress
appendedSession: boolean
} | null {
const boulderState = readBoulderState(input.directory)
if (!boulderState) {
return null
}
const progress = getPlanProgress(boulderState.active_plan)
if (progress.isComplete) {
return { boulderState, progress, appendedSession: false }
}
if (boulderState.session_ids.includes(input.sessionID)) {
return { boulderState, progress, appendedSession: false }
}
if (!subagentSessions.has(input.sessionID)) {
return null
}
const updatedBoulderState = appendSessionId(input.directory, input.sessionID)
if (!updatedBoulderState?.session_ids.includes(input.sessionID)) {
return null
}
return {
boulderState: updatedBoulderState,
progress,
appendedSession: true,
}
}
async function injectContinuation(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
planName: string
progress: { total: number; completed: number }
agent?: string
worktreePath?: string
}): Promise<void> {
const remaining = input.progress.total - input.progress.completed
input.sessionState.lastContinuationInjectedAt = Date.now()
try {
await injectBoulderContinuation({
ctx: input.ctx,
sessionID: input.sessionID,
planName: input.planName,
remaining,
total: input.progress.total,
agent: input.agent,
worktreePath: input.worktreePath,
backgroundManager: input.options?.backgroundManager,
sessionState: input.sessionState,
})
} catch (error) {
log(`[${HOOK_NAME}] Failed to inject boulder continuation`, { sessionID: input.sessionID, error })
input.sessionState.promptFailureCount += 1
}
}
function scheduleRetry(input: {
ctx: PluginInput
sessionID: string
sessionState: SessionState
options?: AtlasHookOptions
}): void {
const { ctx, sessionID, sessionState, options } = input
if (sessionState.pendingRetryTimer) {
return
}
sessionState.pendingRetryTimer = setTimeout(async () => {
sessionState.pendingRetryTimer = undefined
if (sessionState.promptFailureCount >= 2) return
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const currentProgress = getPlanProgress(currentBoulder.active_plan)
if (currentProgress.isComplete) return
if (options?.isContinuationStopped?.(sessionID)) return
if (hasRunningBackgroundTasks(sessionID, options)) return
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: currentBoulder.plan_name,
progress: currentProgress,
agent: currentBoulder.agent,
worktreePath: currentBoulder.worktree_path,
})
}, RETRY_DELAY_MS)
}
export async function handleAtlasSessionIdle(input: {
ctx: PluginInput
options?: AtlasHookOptions
getState: (sessionID: string) => SessionState
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
log(`[${HOOK_NAME}] session.idle`, { sessionID })
const activeBoulderSession = resolveActiveBoulderSession({
directory: ctx.directory,
sessionID,
})
if (!activeBoulderSession) {
log(`[${HOOK_NAME}] Skipped: session not registered in active boulder`, { sessionID })
return
}
const { boulderState, progress, appendedSession } = activeBoulderSession
if (progress.isComplete) {
log(`[${HOOK_NAME}] Boulder complete`, { sessionID, plan: boulderState.plan_name })
return
}
if (appendedSession) {
log(`[${HOOK_NAME}] Appended subagent session to boulder during idle`, {
sessionID,
plan: boulderState.plan_name,
})
}
const sessionState = getState(sessionID)
const now = Date.now()
if (sessionState.lastEventWasAbortError) {
sessionState.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
if (sessionState.promptFailureCount >= 2) {
const timeSinceLastFailure =
sessionState.lastFailureAt !== undefined ? now - sessionState.lastFailureAt : Number.POSITIVE_INFINITY
if (timeSinceLastFailure < FAILURE_BACKOFF_MS) {
log(`[${HOOK_NAME}] Skipped: continuation in backoff after repeated failures`, {
sessionID,
promptFailureCount: sessionState.promptFailureCount,
backoffRemaining: FAILURE_BACKOFF_MS - timeSinceLastFailure,
})
return
}
sessionState.promptFailureCount = 0
sessionState.lastFailureAt = undefined
}
if (hasRunningBackgroundTasks(sessionID, options)) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
if (options?.isContinuationStopped?.(sessionID)) {
log(`[${HOOK_NAME}] Skipped: continuation stopped for session`, { sessionID })
return
}
if (sessionState.lastContinuationInjectedAt && now - sessionState.lastContinuationInjectedAt < CONTINUATION_COOLDOWN_MS) {
scheduleRetry({ ctx, sessionID, sessionState, options })
log(`[${HOOK_NAME}] Skipped: continuation cooldown active`, {
sessionID,
cooldownRemaining: CONTINUATION_COOLDOWN_MS - (now - sessionState.lastContinuationInjectedAt),
pendingRetry: !!sessionState.pendingRetryTimer,
})
return
}
await injectContinuation({
ctx,
sessionID,
sessionState,
options,
planName: boulderState.plan_name,
progress,
agent: boulderState.agent,
worktreePath: boulderState.worktree_path,
})
}
+76 -14
View File
@@ -846,6 +846,71 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
test("should append subagent session to boulder before injecting continuation", async () => {
// given - active boulder plan with another registered session and current session tracked as subagent
const subagentSessionID = "subagent-session-456"
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
subagentSessions.add(subagentSessionID)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when - subagent session goes idle before parent task output appends it
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
// then - session is registered into boulder and continuation is injected
expect(readBoulderState(TEST_DIR)?.session_ids).toContain(subagentSessionID)
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(subagentSessionID)
})
test("should inject when registered boulder session has incomplete tasks even if last agent differs", async () => {
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "hephaestus")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
agent: "atlas",
}
writeBoulderState(TEST_DIR, state)
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.path.id).toBe(MAIN_SESSION_ID)
expect(callArgs.body.parts[0].text).toContain("2 remaining")
})
test("should not inject when boulder plan is complete", async () => {
// given - boulder state with complete plan
const planPath = join(TEST_DIR, "complete-plan.md")
@@ -1083,10 +1148,9 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should not inject when last agent is non-sisyphus and does not match boulder agent", async () => {
// given - boulder explicitly set to atlas, last agent is hephaestus (unrelated agent)
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
test("should inject when registered atlas boulder session last agent does not match", async () => {
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
@@ -1103,17 +1167,15 @@ describe("atlas hook", () => {
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should NOT call prompt because hephaestus does not match atlas or sisyphus
expect(mockInput._promptMock).not.toHaveBeenCalled()
})
expect(mockInput._promptMock).toHaveBeenCalled()
})
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
// given - boulder state expects sisyphus and last agent is sisyphus
@@ -0,0 +1,87 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__")
const TEST_OPENCODE_CACHE_DIR = join(TEST_CACHE_DIR, "opencode")
const TEST_USER_CONFIG_DIR = "/tmp/opencode-config"
mock.module("./constants", () => ({
CACHE_DIR: TEST_OPENCODE_CACHE_DIR,
USER_CONFIG_DIR: TEST_USER_CONFIG_DIR,
PACKAGE_NAME: "oh-my-opencode",
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"), { recursive: true })
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": "latest", other: "1.0.0" } }, null, 2)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "bun.lock"),
JSON.stringify(
{
workspaces: {
"": {
dependencies: { "oh-my-opencode": "latest", other: "1.0.0" },
},
},
packages: {
"oh-my-opencode": {},
other: {},
},
},
null,
2
)
)
writeFileSync(
join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode", "package.json"),
'{"name":"oh-my-opencode"}'
)
}
describe("invalidatePackage", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
})
it("invalidates the installed package from the OpenCode cache directory", async () => {
const { invalidatePackage } = await import("./cache")
const result = invalidatePackage()
expect(result).toBe(true)
expect(existsSync(join(TEST_OPENCODE_CACHE_DIR, "node_modules", "oh-my-opencode"))).toBe(false)
const packageJson = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "package.json"), "utf-8")) as {
dependencies?: Record<string, string>
}
expect(packageJson.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(packageJson.dependencies?.other).toBe("1.0.0")
const bunLock = JSON.parse(readFileSync(join(TEST_OPENCODE_CACHE_DIR, "bun.lock"), "utf-8")) as {
workspaces?: { ""?: { dependencies?: Record<string, string> } }
packages?: Record<string, unknown>
}
expect(bunLock.workspaces?.[""]?.dependencies?.["oh-my-opencode"]).toBe("latest")
expect(bunLock.workspaces?.[""]?.dependencies?.other).toBe("1.0.0")
expect(bunLock.packages?.["oh-my-opencode"]).toBeUndefined()
expect(bunLock.packages?.other).toEqual({})
})
})
+40 -35
View File
@@ -1,6 +1,6 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { CACHE_DIR, PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger"
interface BunLockfile {
@@ -16,65 +16,70 @@ function stripTrailingCommas(json: string): string {
return json.replace(/,(\s*[}\]])/g, "$1")
}
function removeFromBunLock(packageName: string): boolean {
const lockPath = path.join(USER_CONFIG_DIR, "bun.lock")
if (!fs.existsSync(lockPath)) return false
function removeFromTextBunLock(lockPath: string, packageName: string): boolean {
try {
const content = fs.readFileSync(lockPath, "utf-8")
const lock = JSON.parse(stripTrailingCommas(content)) as BunLockfile
let modified = false
if (lock.workspaces?.[""]?.dependencies?.[packageName]) {
delete lock.workspaces[""].dependencies[packageName]
modified = true
}
if (lock.packages?.[packageName]) {
delete lock.packages[packageName]
modified = true
}
if (modified) {
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2))
log(`[auto-update-checker] Removed from bun.lock: ${packageName}`)
return true
}
return modified
return false
} catch {
return false
}
}
function deleteBinaryBunLock(lockPath: string): boolean {
try {
fs.unlinkSync(lockPath)
log(`[auto-update-checker] Removed bun.lockb to force re-resolution`)
return true
} catch {
return false
}
}
function removeFromBunLock(packageName: string): boolean {
const textLockPath = path.join(CACHE_DIR, "bun.lock")
const binaryLockPath = path.join(CACHE_DIR, "bun.lockb")
if (fs.existsSync(textLockPath)) {
return removeFromTextBunLock(textLockPath, packageName)
}
// Binary lockfiles cannot be parsed; deletion forces bun to re-resolve
if (fs.existsSync(binaryLockPath)) {
return deleteBinaryBunLock(binaryLockPath)
}
return false
}
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try {
const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
const pkgDirs = [
path.join(USER_CONFIG_DIR, "node_modules", packageName),
path.join(CACHE_DIR, "node_modules", packageName),
]
let packageRemoved = false
let dependencyRemoved = false
let lockRemoved = false
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
if (fs.existsSync(pkgJsonPath)) {
const content = fs.readFileSync(pkgJsonPath, "utf-8")
const pkgJson = JSON.parse(content)
if (pkgJson.dependencies?.[packageName]) {
delete pkgJson.dependencies[packageName]
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2))
log(`[auto-update-checker] Dependency removed from package.json: ${packageName}`)
dependencyRemoved = true
for (const pkgDir of pkgDirs) {
if (fs.existsSync(pkgDir)) {
fs.rmSync(pkgDir, { recursive: true, force: true })
log(`[auto-update-checker] Package removed: ${pkgDir}`)
packageRemoved = true
}
}
lockRemoved = removeFromBunLock(packageName)
if (!packageRemoved && !dependencyRemoved && !lockRemoved) {
if (!packageRemoved && !lockRemoved) {
log(`[auto-update-checker] Package not found, nothing to invalidate: ${packageName}`)
return false
}
+1
View File
@@ -6,3 +6,4 @@ export { getCachedVersion } from "./checker/cached-version"
export { updatePinnedVersion, revertPinnedVersion } from "./checker/pinned-version-updater"
export { getLatestVersion } from "./checker/latest-version"
export { checkForUpdate } from "./checker/check-for-update"
export { syncCachePackageJsonToIntent } from "./checker/sync-package-json"
@@ -0,0 +1,226 @@
import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import type { PluginEntryInfo } from "./plugin-entry"
const TEST_CACHE_DIR = join(import.meta.dir, "__test-sync-cache__")
mock.module("../constants", () => ({
CACHE_DIR: TEST_CACHE_DIR,
PACKAGE_NAME: "oh-my-opencode",
}))
mock.module("../../../shared/logger", () => ({
log: () => {},
}))
function resetTestCache(currentVersion = "3.10.0"): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { "oh-my-opencode": currentVersion, other: "1.0.0" } }, null, 2)
)
}
function cleanupTestCache(): void {
if (existsSync(TEST_CACHE_DIR)) {
rmSync(TEST_CACHE_DIR, { recursive: true, force: true })
}
}
function readCachePackageJsonVersion(): string | undefined {
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
return pkg.dependencies?.["oh-my-opencode"]
}
describe("syncCachePackageJsonToIntent", () => {
beforeEach(() => {
resetTestCache()
})
afterEach(() => {
cleanupTestCache()
})
describe("#given cache package.json with pinned semver version", () => {
describe("#when opencode.json intent is latest tag", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#when opencode.json intent is next tag", () => {
it("#then updates package.json to use next", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@next",
isPinned: false,
pinnedVersion: "next",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("next")
})
})
describe("#when opencode.json has no version (implies latest)", () => {
it("#then updates package.json to use latest", async () => {
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode",
isPinned: false,
pinnedVersion: null,
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
})
describe("#given cache package.json already matches intent", () => {
it("#then returns false without modifying package.json", async () => {
//#given
resetTestCache("latest")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
expect(readCachePackageJsonVersion()).toBe("latest")
})
})
describe("#given cache package.json does not exist", () => {
it("#then returns false", async () => {
//#given
cleanupTestCache()
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
})
})
describe("#given plugin not in cache package.json dependencies", () => {
it("#then returns false", async () => {
//#given
cleanupTestCache()
mkdirSync(TEST_CACHE_DIR, { recursive: true })
writeFileSync(
join(TEST_CACHE_DIR, "package.json"),
JSON.stringify({ dependencies: { other: "1.0.0" } }, null, 2)
)
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(false)
})
})
describe("#given user explicitly pinned a different semver", () => {
it("#then updates package.json to new version", async () => {
//#given
resetTestCache("3.9.0")
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@3.10.0",
isPinned: true,
pinnedVersion: "3.10.0",
configPath: "/tmp/opencode.json",
}
//#when
const result = syncCachePackageJsonToIntent(pluginInfo)
//#then
expect(result).toBe(true)
expect(readCachePackageJsonVersion()).toBe("3.10.0")
})
})
describe("#given other dependencies exist in cache package.json", () => {
it("#then preserves other dependencies while updating the plugin", async () => {
//#given
const { syncCachePackageJsonToIntent } = await import("./sync-package-json")
const pluginInfo: PluginEntryInfo = {
entry: "oh-my-opencode@latest",
isPinned: false,
pinnedVersion: "latest",
configPath: "/tmp/opencode.json",
}
//#when
syncCachePackageJsonToIntent(pluginInfo)
//#then
const content = readFileSync(join(TEST_CACHE_DIR, "package.json"), "utf-8")
const pkg = JSON.parse(content) as { dependencies?: Record<string, string> }
expect(pkg.dependencies?.other).toBe("1.0.0")
expect(pkg.dependencies?.["oh-my-opencode"]).toBe("latest")
})
})
})
@@ -0,0 +1,63 @@
import * as fs from "node:fs"
import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "../constants"
import { log } from "../../../shared/logger"
import type { PluginEntryInfo } from "./plugin-entry"
interface CachePackageJson {
dependencies?: Record<string, string>
}
function getIntentVersion(pluginInfo: PluginEntryInfo): string {
if (!pluginInfo.pinnedVersion) {
return "latest"
}
return pluginInfo.pinnedVersion
}
/**
* Sync cache package.json to match opencode.json plugin intent before bun install.
*
* OpenCode pins resolved versions in cache package.json (e.g., "3.11.0" instead of "latest").
* When auto-update detects a newer version and runs `bun install`, it re-resolves the pinned
* version instead of the user's declared tag, causing updates to silently fail.
*
* @returns true if package.json was updated, false otherwise
*/
export function syncCachePackageJsonToIntent(pluginInfo: PluginEntryInfo): boolean {
const cachePackageJsonPath = path.join(CACHE_DIR, "package.json")
if (!fs.existsSync(cachePackageJsonPath)) {
log("[auto-update-checker] Cache package.json not found, nothing to sync")
return false
}
try {
const content = fs.readFileSync(cachePackageJsonPath, "utf-8")
const pkgJson = JSON.parse(content) as CachePackageJson
if (!pkgJson.dependencies?.[PACKAGE_NAME]) {
log("[auto-update-checker] Plugin not in cache package.json dependencies, nothing to sync")
return false
}
const currentVersion = pkgJson.dependencies[PACKAGE_NAME]
const intentVersion = getIntentVersion(pluginInfo)
if (currentVersion === intentVersion) {
log("[auto-update-checker] Cache package.json already matches intent:", intentVersion)
return false
}
log(
`[auto-update-checker] Syncing cache package.json: "${currentVersion}" → "${intentVersion}"`
)
pkgJson.dependencies[PACKAGE_NAME] = intentVersion
fs.writeFileSync(cachePackageJsonPath, JSON.stringify(pkgJson, null, 2))
return true
} catch (err) {
log("[auto-update-checker] Failed to sync cache package.json:", err)
return false
}
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "bun:test"
import { join } from "node:path"
import { getOpenCodeCacheDir } from "../../shared/data-path"
describe("auto-update-checker constants", () => {
it("uses the OpenCode cache directory for installed package metadata", async () => {
const { CACHE_DIR, INSTALLED_PACKAGE_JSON, PACKAGE_NAME } = await import(`./constants?test=${Date.now()}`)
expect(CACHE_DIR).toBe(getOpenCodeCacheDir())
expect(INSTALLED_PACKAGE_JSON).toBe(
join(getOpenCodeCacheDir(), "node_modules", PACKAGE_NAME, "package.json")
)
})
})
+4 -10
View File
@@ -1,19 +1,13 @@
import * as path from "node:path"
import * as os from "node:os"
import { getOpenCodeConfigDir } from "../../shared"
import { getOpenCodeCacheDir } from "../../shared/data-path"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
function getCacheDir(): string {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
}
return path.join(os.homedir(), ".cache", "opencode")
}
export const CACHE_DIR = getCacheDir()
export const CACHE_DIR = getOpenCodeCacheDir()
export const VERSION_FILE = path.join(CACHE_DIR, "version")
export function getWindowsAppdataDir(): string | null {
@@ -26,7 +20,7 @@ export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")
export const INSTALLED_PACKAGE_JSON = path.join(
USER_CONFIG_DIR,
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
+34 -44
View File
@@ -54,6 +54,26 @@ function createPluginInput() {
} as never
}
async function flushScheduledWork(): Promise<void> {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
await Promise.resolve()
await Promise.resolve()
}
function runSessionCreatedEvent(
hook: ReturnType<HookFactory>,
properties?: { info?: { parentID?: string } }
): void {
hook.event({
event: {
type: "session.created",
properties,
},
})
}
beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
@@ -85,13 +105,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: undefined } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: undefined } })
await flushScheduledWork()
//#then - no update checker side effects run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -108,12 +123,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives on primary session
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -129,13 +140,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event contains parentID
hook.event({
event: {
type: "session.created",
properties: { info: { parentID: "parent-123" } },
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook, { info: { parentID: "parent-123" } })
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -152,17 +158,9 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event is fired twice
hook.event({
event: {
type: "session.created",
},
})
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -179,12 +177,8 @@ describe("createAutoUpdateCheckerHook", () => {
const hook = createAutoUpdateCheckerHook(createPluginInput())
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
@@ -206,7 +200,7 @@ describe("createAutoUpdateCheckerHook", () => {
type: "session.deleted",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
await flushScheduledWork()
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
@@ -225,12 +219,8 @@ describe("createAutoUpdateCheckerHook", () => {
})
//#when - session.created event arrives
hook.event({
event: {
type: "session.created",
},
})
await new Promise((resolve) => setTimeout(resolve, 50))
runSessionCreatedEvent(hook)
await flushScheduledWork()
//#then - startup toast includes sisyphus wording
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
@@ -51,11 +51,21 @@ mock.module("../../../shared/logger", () => ({ log: () => {} }))
const modulePath = "./background-update-check?test"
const { runBackgroundUpdateCheck } = await import(modulePath)
describe("runBackgroundUpdateCheck", () => {
const mockCtx = { directory: "/test" } as PluginInput
async function runCheck(autoUpdate = true): Promise<void> {
const mockContext = { directory: "/test" } as PluginInput
const getToastMessage: ToastMessageGetter = (isUpdate, version) =>
isUpdate ? `Update to ${version}` : "Up to date"
await runBackgroundUpdateCheck(mockContext, autoUpdate, getToastMessage)
}
function expectNoUpdateEffects(): void {
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
}
describe("runBackgroundUpdateCheck", () => {
beforeEach(() => {
mockFindPluginEntry.mockReset()
mockGetCachedVersion.mockReset()
@@ -73,61 +83,42 @@ describe("runBackgroundUpdateCheck", () => {
mockRunBunInstall.mockResolvedValue(true)
})
describe("#given no plugin entry found", () => {
it("returns early without showing any toast", async () => {
describe("#given no-op scenarios", () => {
it.each([
{
name: "plugin entry is missing",
setup: () => {
mockFindPluginEntry.mockReturnValue(null)
},
},
{
name: "no cached or pinned version exists",
setup: () => {
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
},
},
{
name: "latest version lookup fails",
setup: () => {
mockGetLatestVersion.mockResolvedValue(null)
},
},
{
name: "current version is already latest",
setup: () => {
mockGetLatestVersion.mockResolvedValue("3.4.0")
},
},
])("returns without user-visible update effects when $name", async ({ setup }) => {
//#given
mockFindPluginEntry.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockFindPluginEntry).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expect(mockRunBunInstall).not.toHaveBeenCalled()
})
})
setup()
describe("#given no version available", () => {
it("returns early when neither cached nor pinned version exists", async () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ entry: "oh-my-opencode" }))
mockGetCachedVersion.mockReturnValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetCachedVersion).toHaveBeenCalledTimes(1)
expect(mockGetLatestVersion).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
await runCheck()
describe("#given latest version fetch fails", () => {
it("returns early without toasts", async () => {
//#given
mockGetLatestVersion.mockResolvedValue(null)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledWith("latest")
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
describe("#given already on latest version", () => {
it("returns early without any action", async () => {
//#given
mockGetCachedVersion.mockReturnValue("3.4.0")
mockGetLatestVersion.mockResolvedValue("3.4.0")
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
//#then
expect(mockGetLatestVersion).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
expectNoUpdateEffects()
})
})
@@ -136,9 +127,13 @@ describe("runBackgroundUpdateCheck", () => {
//#given
const autoUpdate = false
//#when
await runBackgroundUpdateCheck(mockCtx, autoUpdate, getToastMessage)
await runCheck(autoUpdate)
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.5.0",
expect.any(Function)
)
expect(mockRunBunInstall).not.toHaveBeenCalled()
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
@@ -149,7 +144,7 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockFindPluginEntry.mockReturnValue(createPluginEntry({ isPinned: true, pinnedVersion: "3.4.0" }))
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).not.toHaveBeenCalled()
@@ -166,7 +161,7 @@ describe("runBackgroundUpdateCheck", () => {
}
)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockShowUpdateAvailableToast).toHaveBeenCalledTimes(1)
expect(capturedToastMessage).toBeDefined()
@@ -184,11 +179,15 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockRunBunInstall.mockResolvedValue(true)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockInvalidatePackage).toHaveBeenCalledTimes(1)
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(mockCtx, "3.4.0", "3.5.0")
expect(mockShowAutoUpdatedToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.4.0",
"3.5.0"
)
expect(mockShowUpdateAvailableToast).not.toHaveBeenCalled()
})
})
@@ -198,10 +197,14 @@ describe("runBackgroundUpdateCheck", () => {
//#given
mockRunBunInstall.mockResolvedValue(false)
//#when
await runBackgroundUpdateCheck(mockCtx, true, getToastMessage)
await runCheck()
//#then
expect(mockRunBunInstall).toHaveBeenCalledTimes(1)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(mockCtx, "3.5.0", getToastMessage)
expect(mockShowUpdateAvailableToast).toHaveBeenCalledWith(
expect.objectContaining({ directory: "/test" }),
"3.5.0",
expect.any(Function)
)
expect(mockShowAutoUpdatedToast).not.toHaveBeenCalled()
})
})
@@ -4,7 +4,7 @@ import { log } from "../../../shared/logger"
import { invalidatePackage } from "../cache"
import { PACKAGE_NAME } from "../constants"
import { extractChannel } from "../version-channel"
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion } from "../checker"
import { findPluginEntry, getCachedVersion, getLatestVersion, revertPinnedVersion, syncCachePackageJsonToIntent } from "../checker"
import { showAutoUpdatedToast, showUpdateAvailableToast } from "./update-toasts"
function getPinnedVersionToastMessage(latestVersion: string): string {
@@ -65,6 +65,7 @@ export async function runBackgroundUpdateCheck(
return
}
syncCachePackageJsonToIntent(pluginInfo)
invalidatePackage(PACKAGE_NAME)
const installSuccess = await runBunInstallSafe()
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, mock } from "bun:test"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
const transcriptCalls: Array<[string, unknown]> = []
const appendTranscriptEntry = mock((sessionId: string, entry: unknown) => {
transcriptCalls.push([sessionId, entry])
})
mock.module("../config", () => ({
loadClaudeHooksConfig: async () => ({}),
}))
mock.module("../config-loader", () => ({
loadPluginExtendedConfig: async () => ({}),
}))
mock.module("../post-tool-use", () => ({
executePostToolUseHooks: async () => ({ warnings: [] }),
}))
mock.module("../transcript", () => ({
appendTranscriptEntry,
getTranscriptPath: () => "/tmp/transcript.jsonl",
}))
const { createToolExecuteAfterHandler } = await import("./tool-execute-after-handler")
describe("createToolExecuteAfterHandler", () => {
beforeEach(() => {
appendTranscriptEntry.mockClear()
transcriptCalls.length = 0
})
it("#given diff-heavy metadata #when transcript entry is appended #then it keeps concise output with compact metadata", async () => {
const handler = createToolExecuteAfterHandler(
{
client: {
tui: {
showToast: async () => ({}),
},
},
directory: "/repo",
} as never,
{ disabledHooks: ["PostToolUse"] }
)
await handler(
{ tool: "hashline_edit", sessionID: "ses_test", callID: "call_test" },
{
title: "src/example.ts",
output: "Updated src/example.ts",
metadata: {
filePath: "src/example.ts",
path: "src/duplicate-path.ts",
file: "src/duplicate-file.ts",
sessionId: "ses_oracle",
agent: "oracle",
prompt: "very large hidden prompt",
diff: "x".repeat(5000),
noopEdits: 1,
deduplicatedEdits: 2,
firstChangedLine: 42,
filediff: {
before: "before body",
after: "after body",
additions: 3,
deletions: 4,
},
nested: {
keep: false,
},
},
}
)
expect(appendTranscriptEntry).toHaveBeenCalledTimes(1)
const firstCall = transcriptCalls[0]
const sessionId = firstCall?.[0]
const entry = firstCall?.[1]
expect(sessionId).toBe("ses_test")
expect(entry).toBeDefined()
if (!entry || typeof entry !== "object" || !("tool_output" in entry)) {
throw new Error("expected transcript entry with tool_output")
}
const toolOutput = entry.tool_output
expect(toolOutput).toBeDefined()
if (!isRecord(toolOutput)) {
throw new Error("expected compact tool_output object")
}
expect(entry).toMatchObject({
type: "tool_result",
tool_name: "hashline_edit",
tool_input: {},
tool_output: {
output: "Updated src/example.ts",
filePath: "src/example.ts",
sessionId: "ses_oracle",
agent: "oracle",
noopEdits: 1,
deduplicatedEdits: 2,
firstChangedLine: 42,
filediff: {
additions: 3,
deletions: 4,
},
},
})
expect(entry).toHaveProperty("timestamp")
expect(toolOutput).not.toHaveProperty("diff")
expect(toolOutput).not.toHaveProperty("path")
expect(toolOutput).not.toHaveProperty("file")
expect(toolOutput).not.toHaveProperty("prompt")
expect(toolOutput).not.toHaveProperty("nested")
const filediff = toolOutput.filediff
expect(filediff).toBeDefined()
if (!isRecord(filediff)) {
throw new Error("expected compact filediff object")
}
expect(filediff).not.toHaveProperty("before")
expect(filediff).not.toHaveProperty("after")
})
})
@@ -11,6 +11,65 @@ import { appendTranscriptEntry, getTranscriptPath } from "../transcript"
import type { PluginConfig } from "../types"
import { isHookDisabled } from "../../../shared"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function getStringValue(record: Record<string, unknown>, key: string): string | undefined {
const value = record[key]
return typeof value === "string" && value.length > 0 ? value : undefined
}
function getNumberValue(record: Record<string, unknown>, key: string): number | undefined {
const value = record[key]
return typeof value === "number" ? value : undefined
}
function buildTranscriptToolOutput(outputText: string, metadata: unknown): Record<string, unknown> {
const compactOutput: Record<string, unknown> = { output: outputText }
if (!isRecord(metadata)) {
return compactOutput
}
const filePath = getStringValue(metadata, "filePath")
?? getStringValue(metadata, "path")
?? getStringValue(metadata, "file")
if (filePath) {
compactOutput.filePath = filePath
}
const sessionId = getStringValue(metadata, "sessionId")
if (sessionId) {
compactOutput.sessionId = sessionId
}
const agent = getStringValue(metadata, "agent")
if (agent) {
compactOutput.agent = agent
}
for (const key of ["noopEdits", "deduplicatedEdits", "firstChangedLine"] as const) {
const value = getNumberValue(metadata, key)
if (value !== undefined) {
compactOutput[key] = value
}
}
const filediff = metadata.filediff
if (isRecord(filediff)) {
const additions = getNumberValue(filediff, "additions")
const deletions = getNumberValue(filediff, "deletions")
if (additions !== undefined || deletions !== undefined) {
compactOutput.filediff = {
...(additions !== undefined ? { additions } : {}),
...(deletions !== undefined ? { deletions } : {}),
}
}
}
return compactOutput
}
export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) {
return async (
input: { tool: string; sessionID: string; callID: string },
@@ -25,17 +84,12 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
const metadata = output.metadata as Record<string, unknown> | undefined
const hasMetadata =
metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
const toolOutput = hasMetadata ? metadata : { output: output.output }
appendTranscriptEntry(input.sessionID, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: input.tool,
tool_input: cachedInput,
tool_output: toolOutput,
tool_output: buildTranscriptToolOutput(output.output, output.metadata),
})
if (isHookDisabled(config, "PostToolUse")) {
@@ -0,0 +1,93 @@
/// <reference types="bun-types" />
import { describe, expect, it } from "bun:test"
import { createContextWindowMonitorHook } from "./context-window-monitor"
function createOutput() {
return { title: "", output: "original", metadata: null }
}
describe("context-window-monitor modelContextLimitsCache", () => {
it("does not append reminder below cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_below_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 150000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toBe("original")
})
it("appends reminder above cached non-anthropic threshold", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("opencode/kimi-k2.5-free", 262144)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_non_anthropic_above_threshold"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "opencode",
modelID: "kimi-k2.5-free",
finish: true,
tokens: {
input: 180000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then
expect(output.output).toContain("context remaining")
expect(output.output).toContain("262,144-token context window")
expect(output.output).toContain("[Context Status: 72.5% used (190,000/262,144 tokens), 27.5% remaining]")
expect(output.output).not.toContain("1,000,000")
})
})
+24 -12
View File
@@ -1,12 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
const DEFAULT_ANTHROPIC_ACTUAL_LIMIT = 200_000
const CONTEXT_WARNING_THRESHOLD = 0.70
type ModelCacheStateLike = {
anthropicContext1MEnabled: boolean
modelContextLimitsCache?: Map<string, number>
}
function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number {
@@ -17,11 +17,15 @@ function getAnthropicActualLimit(modelCacheState?: ModelCacheStateLike): number
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT
}
const CONTEXT_REMINDER = `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
function createContextReminder(actualLimit: number): string {
const limitTokens = actualLimit.toLocaleString()
You are using Anthropic Claude with 1M context window.
You have plenty of context remaining - do NOT rush or skip tasks.
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
You are using a ${limitTokens}-token context window.
You still have context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
}
interface TokenInfo {
input: number
@@ -32,6 +36,7 @@ interface TokenInfo {
interface CachedTokenState {
providerID: string
modelID: string
tokens: TokenInfo
}
@@ -57,25 +62,30 @@ export function createContextWindowMonitorHook(
const cached = tokenCache.get(sessionID)
if (!cached) return
if (!isAnthropicProvider(cached.providerID)) return
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(
`${cached.providerID}/${cached.modelID}`
)
const actualLimit =
cachedLimit ??
(isAnthropicProvider(cached.providerID) ? getAnthropicActualLimit(modelCacheState) : null)
if (!actualLimit) return
const lastTokens = cached.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const actualUsagePercentage =
totalInputTokens / getAnthropicActualLimit(modelCacheState)
const actualUsagePercentage = totalInputTokens / actualLimit
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
remindedSessions.add(sessionID)
const displayUsagePercentage = totalInputTokens / ANTHROPIC_DISPLAY_LIMIT
const usedPct = (displayUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - displayUsagePercentage) * 100).toFixed(1)
const usedPct = (actualUsagePercentage * 100).toFixed(1)
const remainingPct = ((1 - actualUsagePercentage) * 100).toFixed(1)
const usedTokens = totalInputTokens.toLocaleString()
const limitTokens = ANTHROPIC_DISPLAY_LIMIT.toLocaleString()
const limitTokens = actualLimit.toLocaleString()
output.output += `\n\n${CONTEXT_REMINDER}
output.output += `\n\n${createContextReminder(actualLimit)}
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
}
@@ -95,6 +105,7 @@ export function createContextWindowMonitorHook(
role?: string
sessionID?: string
providerID?: string
modelID?: string
finish?: boolean
tokens?: TokenInfo
} | undefined
@@ -104,6 +115,7 @@ export function createContextWindowMonitorHook(
tokenCache.set(info.sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
})
}
@@ -18,7 +18,7 @@ export function findAgentsMdUp(input: {
while (true) {
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
// See: https://github.com/code-yeongyu/oh-my-openagent/issues/379
const isRootDir = current === input.rootDir;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
+18 -3
View File
@@ -14,6 +14,14 @@ import {
import type { ContextCollector } from "../../features/context-injector"
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
function getRuntimeVariant(input: { variant?: string }, message: Record<string, unknown>): string | undefined {
if (typeof message["variant"] === "string") {
return message["variant"]
}
return typeof input.variant === "string" ? input.variant : undefined
}
return {
"chat.message": async (
input: {
@@ -21,6 +29,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
variant?: string
},
output: {
message: Record<string, unknown>
@@ -72,15 +81,21 @@ export function createKeywordDetectorHook(ctx: PluginInput, _collector?: Context
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
if (hasUltrawork) {
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
const runtimeVariant = getRuntimeVariant(input, output.message)
const isRuntimeMax = runtimeVariant === "max"
output.message.variant = "max"
log(`[keyword-detector] Ultrawork mode activated`, {
sessionID: input.sessionID,
runtimeVariant,
})
ctx.client.tui
.showToast({
body: {
title: "Ultrawork Mode Activated",
message: "Maximum precision engaged. All agents at your disposal.",
message: isRuntimeMax
? "Maximum precision engaged. All agents at your disposal."
: "Runtime variant preserved. All agents at your disposal.",
variant: "success" as const,
duration: 3000,
},
+9 -9
View File
@@ -169,8 +169,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should still work (variant set to max)
expect(output.message.variant).toBe("max")
// then - ultrawork should still work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -214,12 +214,12 @@ describe("keyword-detector session filtering", () => {
output
)
// then - all keywords should work
expect(output.message.variant).toBe("max")
// then - all keywords should work without forcing a new variant
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
test("should override existing variant when ultrawork keyword is used", async () => {
test("should preserve existing runtime variant when ultrawork keyword is used", async () => {
// given - main session set with pre-existing variant from TUI
setMainSession("main-123")
@@ -236,8 +236,8 @@ describe("keyword-detector session filtering", () => {
output
)
// then - ultrawork should override TUI variant to max
expect(output.message.variant).toBe("max")
// then - ultrawork should preserve the already resolved runtime variant
expect(output.message.variant).toBe("low")
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
})
@@ -311,8 +311,8 @@ describe("keyword-detector word boundary", () => {
output
)
// then - ultrawork should be triggered
expect(output.message.variant).toBe("max")
// then - ultrawork should be triggered without forcing max
expect(output.message.variant).toBeUndefined()
expect(toastCalls).toContain("Ultrawork Mode Activated")
})
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { createKeywordDetectorHook } from "./index"
import { _resetForTesting, setMainSession } from "../../features/claude-code-session-state"
function createMockPluginInput(toastMessages: string[]) {
return {
client: {
tui: {
showToast: async (opts: { body: { message: string } }) => {
toastMessages.push(opts.body.message)
},
},
},
} as any
}
describe("keyword-detector ultrawork runtime variant gating", () => {
test("#given runtime max variant #when ultrawork activates #then maximum precision toast is preserved", async () => {
// given
_resetForTesting()
setMainSession("main-session")
const toastMessages: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
const output = {
message: { variant: "max" } as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork do it" }],
}
// when
await hook["chat.message"]({ sessionID: "main-session", variant: "max" }, output)
// then
expect(output.message.variant).toBe("max")
expect(toastMessages).toEqual(["Maximum precision engaged. All agents at your disposal."])
_resetForTesting()
})
test("#given runtime non-max variant #when ultrawork activates #then variant stays unchanged and toast does not claim max", async () => {
// given
_resetForTesting()
setMainSession("main-session")
const toastMessages: string[] = []
const hook = createKeywordDetectorHook(createMockPluginInput(toastMessages))
const output = {
message: { variant: "medium" } as Record<string, unknown>,
parts: [{ type: "text", text: "ultrawork do it" }],
}
// when
await hook["chat.message"]({ sessionID: "main-session", variant: "medium" }, output)
// then
expect(output.message.variant).toBe("medium")
expect(toastMessages).toEqual(["Runtime variant preserved. All agents at your disposal."])
_resetForTesting()
})
})
+116 -1
View File
@@ -140,6 +140,121 @@ describe("model fallback hook", () => {
expect(secondOutput.message["variant"]).toBeUndefined()
})
test("does not re-arm fallback when one is already pending", () => {
//#given
const sessionID = "ses_model_fallback_pending_guard"
clearPendingModelFallback(sessionID)
//#when
const firstSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
const secondSet = setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6-thinking",
)
//#then
expect(firstSet).toBe(true)
expect(secondSet).toBe(false)
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries that resolve to same provider/model", async () => {
//#given
const sessionID = "ses_model_fallback_noop_skip"
clearPendingModelFallback(sessionID)
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
{ providers: ["anthropic"], model: "claude-opus-4-6" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"anthropic",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "opencode",
modelID: "kimi-k2.5-free",
})
clearPendingModelFallback(sessionID)
})
test("skips no-op fallback entries even when variant differs", async () => {
//#given
const sessionID = "ses_model_fallback_noop_variant_skip"
clearPendingModelFallback(sessionID)
const hook = createModelFallbackHook() as unknown as {
"chat.message"?: (
input: { sessionID: string },
output: { message: Record<string, unknown>; parts: Array<{ type: string; text?: string }> },
) => Promise<void>
}
setSessionFallbackChain(sessionID, [
{ providers: ["quotio"], model: "claude-opus-4-6", variant: "max" },
{ providers: ["quotio"], model: "gpt-5.2" },
])
expect(
setPendingModelFallback(
sessionID,
"Sisyphus (Ultraworker)",
"quotio",
"claude-opus-4-6",
),
).toBe(true)
const output = {
message: {
model: { providerID: "quotio", modelID: "claude-opus-4-6" },
variant: "max",
},
parts: [{ type: "text", text: "continue" }],
}
//#when
await hook["chat.message"]?.({ sessionID }, output)
//#then
expect(output.message["model"]).toEqual({
providerID: "quotio",
modelID: "gpt-5.2",
})
expect(output.message["variant"]).toBeUndefined()
clearPendingModelFallback(sessionID)
})
test("shows toast when fallback is applied", async () => {
//#given
const toastCalls: Array<{ title: string; message: string }> = []
@@ -199,7 +314,7 @@ describe("model fallback hook", () => {
sessionID,
"Atlas (Plan Executor)",
"github-copilot",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
)
expect(set).toBe(true)
+23 -1
View File
@@ -39,6 +39,12 @@ const pendingModelFallbacks = new Map<string, ModelFallbackState>()
const lastToastKey = new Map<string, string>()
const sessionFallbackChains = new Map<string, FallbackEntry[]>()
function canonicalizeModelID(modelID: string): string {
return modelID
.toLowerCase()
.replace(/\./g, "-")
}
export function setSessionFallbackChain(sessionID: string, fallbackChain: FallbackEntry[] | undefined): void {
if (!sessionID) return
if (!fallbackChain || fallbackChain.length === 0) {
@@ -77,6 +83,11 @@ export function setPendingModelFallback(
const existing = pendingModelFallbacks.get(sessionID)
if (existing) {
if (existing.pending) {
log("[model-fallback] Pending fallback already armed for session: " + sessionID)
return false
}
// Preserve progression across repeated session.error retries in same session.
// We only mark the next turn as pending fallback application.
existing.providerID = currentProviderID
@@ -140,13 +151,24 @@ export function getNextFallback(
}
const providerID = selectFallbackProvider(fallback.providers, state.providerID)
const modelID = transformModelForProvider(providerID, fallback.model)
const isNoOpFallback =
providerID.toLowerCase() === state.providerID.toLowerCase() &&
canonicalizeModelID(modelID) === canonicalizeModelID(state.modelID)
if (isNoOpFallback) {
log("[model-fallback] Skipping no-op fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
continue
}
state.pending = false
log("[model-fallback] Using fallback for session: " + sessionID + ", attempt: " + attemptCount + ", model: " + fallback.model)
return {
providerID,
modelID: transformModelForProvider(providerID, fallback.model),
modelID,
variant: fallback.variant,
}
}
+4
View File
@@ -26,6 +26,10 @@ export const RETRYABLE_ERROR_PATTERNS = [
/rate.?limit/i,
/too.?many.?requests/i,
/quota.?exceeded/i,
/quota\s+will\s+reset\s+after/i,
/all\s+credentials\s+for\s+model/i,
/cool(?:ing)?\s+down/i,
/exhausted\s+your\s+capacity/i,
/usage\s+limit\s+has\s+been\s+reached/i,
/service.?unavailable/i,
/overloaded/i,
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { extractAutoRetrySignal, isRetryableError } from "./error-classifier"
describe("runtime-fallback error classifier", () => {
test("detects cooling-down auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("detects single-word cooldown auto-retry status signals", () => {
//#given
const info = {
status:
"All credentials for model claude-opus-4-6 are cooldown [retrying in 7m 56s attempt #1]",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeDefined()
})
test("treats cooling-down retry messages as retryable", () => {
//#given
const error = {
message:
"All credentials for model claude-opus-4-6-thinking are cooling down [retrying in ~5 days attempt #1]",
}
//#when
const retryable = isRetryableError(error, [400, 403, 408, 429, 500, 502, 503, 504, 529])
//#then
expect(retryable).toBe(true)
})
test("ignores non-retry assistant status text", () => {
//#given
const info = {
status: "Thinking...",
}
//#when
const signal = extractAutoRetrySignal(info)
//#then
expect(signal).toBeUndefined()
})
})
@@ -102,7 +102,7 @@ export interface AutoRetrySignal {
export const AUTO_RETRY_PATTERNS: Array<(combined: string) => boolean> = [
(combined) => /retrying\s+in/i.test(combined),
(combined) =>
/(?:too\s+many\s+requests|quota\s*exceeded|usage\s+limit|rate\s+limit|limit\s+reached)/i.test(combined),
/(?:too\s+many\s+requests|quota\s*exceeded|quota\s+will\s+reset\s+after|usage\s+limit|rate\s+limit|limit\s+reached|all\s+credentials\s+for\s+model|cool(?:ing)?\s*down|exhausted\s+your\s+capacity)/i.test(combined),
]
export function extractAutoRetrySignal(info: Record<string, unknown> | undefined): AutoRetrySignal | undefined {
+87 -1
View File
@@ -2,13 +2,15 @@ import type { HookDeps } from "./types"
import type { AutoRetryHelpers } from "./auto-retry"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError } from "./error-classifier"
import { extractStatusCode, extractErrorName, classifyErrorType, isRetryableError, extractAutoRetrySignal } from "./error-classifier"
import { createFallbackState, prepareFallback } from "./fallback-state"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { normalizeRetryStatusMessage, extractRetryAttempt } from "../../shared/retry-status-utils"
export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
const { config, pluginConfig, sessionStates, sessionLastAccess, sessionRetryInFlight, sessionAwaitingFallbackResult, sessionFallbackTimeouts } = deps
const sessionStatusRetryKeys = new Map<string, string>()
const handleSessionCreated = (props: Record<string, unknown> | undefined) => {
const sessionInfo = props?.info as { id?: string; model?: string } | undefined
@@ -33,6 +35,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
sessionRetryInFlight.delete(sessionID)
sessionAwaitingFallbackResult.delete(sessionID)
helpers.clearSessionFallbackTimeout(sessionID)
sessionStatusRetryKeys.delete(sessionID)
SessionCategoryRegistry.remove(sessionID)
}
}
@@ -182,6 +185,88 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
}
}
const handleSessionStatus = async (props: Record<string, unknown> | undefined) => {
const sessionID = props?.sessionID as string | undefined
const status = props?.status as { type?: string; message?: string; attempt?: number } | undefined
const agent = props?.agent as string | undefined
const model = props?.model as string | undefined
if (!sessionID || status?.type !== "retry") return
const retryMessage = typeof status.message === "string" ? status.message : ""
const retrySignal = extractAutoRetrySignal({ status: retryMessage, message: retryMessage })
if (!retrySignal) return
const retryKey = `${extractRetryAttempt(status.attempt, retryMessage)}:${normalizeRetryStatusMessage(retryMessage)}`
if (sessionStatusRetryKeys.get(sessionID) === retryKey) {
return
}
sessionStatusRetryKeys.set(sessionID, retryKey)
if (sessionRetryInFlight.has(sessionID)) {
log(`[${HOOK_NAME}] session.status retry skipped — retry already in flight`, { sessionID })
return
}
const resolvedAgent = await helpers.resolveAgentForSessionFromContext(sessionID, agent)
const fallbackModels = getFallbackModelsForSession(sessionID, resolvedAgent, pluginConfig)
if (fallbackModels.length === 0) return
let state = sessionStates.get(sessionID)
if (!state) {
const detectedAgent = resolvedAgent
const agentConfig = detectedAgent
? pluginConfig?.agents?.[detectedAgent as keyof typeof pluginConfig.agents]
: undefined
const inferredModel = model || (agentConfig?.model as string | undefined)
if (!inferredModel) {
log(`[${HOOK_NAME}] session.status retry missing model info, cannot fallback`, { sessionID })
return
}
state = createFallbackState(inferredModel)
sessionStates.set(sessionID, state)
}
sessionLastAccess.set(sessionID, Date.now())
if (state.pendingFallbackModel) {
log(`[${HOOK_NAME}] session.status retry skipped (pending fallback in progress)`, {
sessionID,
pendingFallbackModel: state.pendingFallbackModel,
})
return
}
log(`[${HOOK_NAME}] Detected provider auto-retry signal in session.status`, {
sessionID,
model: state.currentModel,
retryAttempt: status.attempt,
})
await helpers.abortSessionRequest(sessionID, "session.status.retry-signal")
const result = prepareFallback(sessionID, state, fallbackModels, config)
if (result.success && config.notify_on_fallback) {
await deps.ctx.client.tui
.showToast({
body: {
title: "Model Fallback",
message: `Switching to ${result.newModel?.split("/").pop() || result.newModel} for next request`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
}
if (result.success && result.newModel) {
await helpers.autoRetryWithFallback(sessionID, result.newModel, resolvedAgent, "session.status")
}
if (!result.success) {
log(`[${HOOK_NAME}] Fallback preparation failed`, { sessionID, error: result.error })
}
}
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (!config.enabled) return
@@ -191,6 +276,7 @@ export function createEventHandler(deps: HookDeps, helpers: AutoRetryHelpers) {
if (event.type === "session.deleted") { handleSessionDeleted(props); return }
if (event.type === "session.stop") { await handleSessionStop(props); return }
if (event.type === "session.idle") { handleSessionIdle(props); return }
if (event.type === "session.status") { await handleSessionStatus(props); return }
if (event.type === "session.error") { await handleSessionError(props); return }
}
}
@@ -0,0 +1,66 @@
import { afterEach, describe, expect, test } from "bun:test"
import { getFallbackModelsForSession } from "./fallback-models"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
describe("runtime-fallback fallback-models", () => {
afterEach(() => {
SessionCategoryRegistry.clear()
})
test("uses category fallback_models when session category is registered", () => {
//#given
const sessionID = "ses_runtime_fallback_category"
SessionCategoryRegistry.register(sessionID, "quick")
const pluginConfig = {
categories: {
quick: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession(sessionID, undefined, pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("uses agent-specific fallback_models when agent is resolved", () => {
//#given
const pluginConfig = {
agents: {
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_agent", "oracle", pluginConfig)
//#then
expect(result).toEqual(["openai/gpt-5.2", "anthropic/claude-opus-4-6"])
})
test("does not fall back to another agent chain when agent cannot be resolved", () => {
//#given
const pluginConfig = {
agents: {
sisyphus: {
fallback_models: ["quotio/gpt-5.2", "quotio/glm-5", "quotio/kimi-k2.5"],
},
oracle: {
fallback_models: ["openai/gpt-5.2", "anthropic/claude-opus-4-6"],
},
},
} as any
//#when
const result = getFallbackModelsForSession("ses_runtime_fallback_unknown", undefined, pluginConfig)
//#then
expect(result).toEqual([])
})
})
+2 -14
View File
@@ -1,5 +1,5 @@
import type { OhMyOpenCodeConfig } from "../../config"
import { AGENT_NAMES, agentPattern } from "./agent-resolver"
import { agentPattern } from "./agent-resolver"
import { HOOK_NAME } from "./constants"
import { log } from "../../shared/logger"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
@@ -51,19 +51,7 @@ export function getFallbackModelsForSession(
if (result) return result
}
const sisyphusFallback = tryGetFallbackFromAgent("sisyphus")
if (sisyphusFallback) {
log(`[${HOOK_NAME}] Using sisyphus fallback models (no agent detected)`, { sessionID })
return sisyphusFallback
}
for (const agentName of AGENT_NAMES) {
const result = tryGetFallbackFromAgent(agentName)
if (result) {
log(`[${HOOK_NAME}] Using ${agentName} fallback models (no agent detected)`, { sessionID })
return result
}
}
log(`[${HOOK_NAME}] No category/agent fallback models resolved for session`, { sessionID, agent })
return []
}
+213
View File
@@ -387,6 +387,219 @@ describe("runtime-fallback", () => {
expect(fallbackLog?.data).toMatchObject({ from: "openai/gpt-5.3-codex", to: "anthropic/claude-opus-4-6" })
})
test("should trigger fallback on auto-retry signal in assistant text parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
},
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback when auto-retry text parts are nested under info.parts", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
})
const sessionID = "test-session-info-parts-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
sessionID,
role: "assistant",
model: "quotio/claude-opus-4-6",
parts: [
{
type: "text",
text: "This request would exceed your account's rate limit. Please try again later. [retrying in 2s attempt #2]",
},
],
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
})
test("should trigger fallback on session.status auto-retry signal", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-auto-retry"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
const signalLog = logCalls.find((c) => c.msg.includes("Detected provider auto-retry signal in session.status"))
expect(signalLog).toBeDefined()
const fallbackLog = logCalls.find((c) => c.msg.includes("Preparing fallback"))
expect(fallbackLog).toBeDefined()
expect(fallbackLog?.data).toMatchObject({ from: "quotio/claude-opus-4-6", to: "openai/gpt-5.2" })
expect(promptCalls.length).toBe(1)
})
test("should deduplicate session.status countdown updates for the same retry attempt", async () => {
const promptCalls: unknown[] = []
const hook = createRuntimeFallbackHook(
createMockPluginInput({
session: {
messages: async () => ({
data: [
{
info: { role: "user" },
parts: [{ type: "text", text: "continue" }],
},
],
}),
promptAsync: async (args) => {
promptCalls.push(args)
return {}
},
},
}),
{
config: createMockConfig({ notify_on_fallback: false }),
pluginConfig: createMockPluginConfigWithCategoryFallback(["openai/gpt-5.2"]),
}
)
const sessionID = "test-session-status-dedup"
SessionCategoryRegistry.register(sessionID, "test")
await hook.event({
event: {
type: "session.created",
properties: { info: { id: sessionID, model: "quotio/claude-opus-4-6" } },
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 476,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 56s attempt #1]",
},
},
},
})
await hook.event({
event: {
type: "session.status",
properties: {
sessionID,
status: {
type: "retry",
next: 475,
attempt: 1,
message: "All credentials for model claude-opus-4-6 are cooling down [retrying in 7m 55s attempt #1]",
},
},
},
})
expect(promptCalls.length).toBe(1)
})
test("should NOT trigger fallback on auto-retry signal when timeout_seconds is 0", async () => {
const hook = createRuntimeFallbackHook(createMockPluginInput(), {
config: createMockConfig({ notify_on_fallback: false, timeout_seconds: 0 }),

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